fix(android): deliver cleaned files on Android APK (#186) #189

Merged
forgejo_admin merged 23 commits from fix/android-downloads into master 2026-05-22 18:19:11 +00:00

Problem

On Android Capacitor WebView, URL.createObjectURL() produces a blob:https://localhost/… URL. Android's DownloadManager (which Capacitor delegates to) only handles http:// and https:// URLs — it cannot resolve a blob URL. The programmatic <a download> + anchor.click() path silently does nothing on Android. Cleaned files were never reaching the device.

Affects both single-file output and the batch-zip path.

Solution

On Android native (Capacitor.isNativePlatform() && platform === "android"), BrowserFileBytes.write() now saves cleaned bytes to the public Download/ folder via @capacitor/filesystem instead of the blob-URL anchor trick. A per-row Share button and a batch "Share zip" button call navigator.share({ files: [...] }) from a user-gesture click handler so users can immediately forward the file. Desktop behaviour is unchanged.

Changes

  • src/infrastructure/web/platform.ts (new) — isNativeAndroid() helper using @capacitor/core
  • src/infrastructure/web/browser_file_bytes.ts — Android write path via Filesystem.writeFile(), bytes stash for Share button, toBase64() loop (no spread, avoids stack overflow)
  • src/infrastructure/web/batch_output.tstriggerDownload callback accepts Promise<void> for async zip save
  • src/infrastructure/web/web_api.tsPlatformApi.isNativeAndroid, FilesApi.shareFile + notifyClearFiles, triggerZipSave Android fork, ZIP_STASH_KEY export
  • src/web/contexts/AppContext.tsxoutputPath: string | null added to FileEntry + UPDATE_FILE_METADATA action
  • src/web/hooks/use_process_files.ts — populate outputPath from result.outputPath
  • src/web/components/icons/ShareIcon.tsx (new) — inline SVG share icon
  • src/web/components/file-list/FileRow.tsx — per-row Share button (Android native only, isNativeAndroid)
  • src/web/components/file-list/FileTable.tsx — batch zip Share button + metascrub:saved-to-downloads toast listener + metascrub:batch-zip-saved listener
  • src/web/styles/file_table.css.file-table__share and .file-table__share-zip-btn styles
  • android/app/src/main/AndroidManifest.xmlWRITE_EXTERNAL_STORAGE maxSdkVersion="28" (only needed on Android ≤9)
  • .resources/strings.jsonsavedToDownloads, shareFile, shareZip keys (25 locales)
  • package.json@capacitor/filesystem ^7 (devDependencies)

Screenshots (simulated Android mode — isNativeAndroid forced true in browser)

Empty state (Android, mobile viewport 390px):

Empty state Android

After processing sample.jpg — Share button confirmed in DOM (1 instance), "Saved to Downloads" toast fires on save:

File table after processing

Note: screenshots are taken in a desktop browser with isNativeAndroid mocked. On a real Android device the reveal icon is hidden (via existing display: none in the mobile media query) and the share icon is fully visible in the 110px RESULT column.

Test plan

  • yarn lint && yarn typecheck && yarn test && yarn check:deps — all pass (519 tests)
  • yarn build && yarn build:web:standalone — both build clean
  • New unit tests: tests/infrastructure/web/browser_file_bytes.test.ts (7 tests) — stash behaviour, Android write path, event dispatch, no anchor click on Android
  • Desktop path: existing <a download> behaviour is unchanged; all existing tests still pass

Closes #186

## Problem On Android Capacitor WebView, `URL.createObjectURL()` produces a `blob:https://localhost/…` URL. Android's `DownloadManager` (which Capacitor delegates to) only handles `http://` and `https://` URLs — it cannot resolve a blob URL. The programmatic `<a download>` + `anchor.click()` path silently does nothing on Android. Cleaned files were never reaching the device. Affects both single-file output and the batch-zip path. ## Solution On Android native (`Capacitor.isNativePlatform() && platform === "android"`), `BrowserFileBytes.write()` now saves cleaned bytes to the public `Download/` folder via `@capacitor/filesystem` instead of the blob-URL anchor trick. A per-row Share button and a batch "Share zip" button call `navigator.share({ files: [...] })` from a user-gesture click handler so users can immediately forward the file. Desktop behaviour is unchanged. ## Changes - **`src/infrastructure/web/platform.ts`** (new) — `isNativeAndroid()` helper using `@capacitor/core` - **`src/infrastructure/web/browser_file_bytes.ts`** — Android write path via `Filesystem.writeFile()`, bytes stash for Share button, `toBase64()` loop (no spread, avoids stack overflow) - **`src/infrastructure/web/batch_output.ts`** — `triggerDownload` callback accepts `Promise<void>` for async zip save - **`src/infrastructure/web/web_api.ts`** — `PlatformApi.isNativeAndroid`, `FilesApi.shareFile` + `notifyClearFiles`, `triggerZipSave` Android fork, `ZIP_STASH_KEY` export - **`src/web/contexts/AppContext.tsx`** — `outputPath: string | null` added to `FileEntry` + `UPDATE_FILE_METADATA` action - **`src/web/hooks/use_process_files.ts`** — populate `outputPath` from `result.outputPath` - **`src/web/components/icons/ShareIcon.tsx`** (new) — inline SVG share icon - **`src/web/components/file-list/FileRow.tsx`** — per-row Share button (Android native only, `isNativeAndroid`) - **`src/web/components/file-list/FileTable.tsx`** — batch zip Share button + `metascrub:saved-to-downloads` toast listener + `metascrub:batch-zip-saved` listener - **`src/web/styles/file_table.css`** — `.file-table__share` and `.file-table__share-zip-btn` styles - **`android/app/src/main/AndroidManifest.xml`** — `WRITE_EXTERNAL_STORAGE maxSdkVersion="28"` (only needed on Android ≤9) - **`.resources/strings.json`** — `savedToDownloads`, `shareFile`, `shareZip` keys (25 locales) - **`package.json`** — `@capacitor/filesystem ^7` (devDependencies) ## Screenshots (simulated Android mode — `isNativeAndroid` forced true in browser) **Empty state (Android, mobile viewport 390px):** ![Empty state Android](http://forgejo.localhost:3000/attachments/efab8733-3e1c-402e-9e32-887571a21149) **After processing sample.jpg — Share button confirmed in DOM (1 instance), "Saved to Downloads" toast fires on save:** ![File table after processing](http://forgejo.localhost:3000/attachments/a57732aa-d01b-4620-b548-8a63321cadf0) Note: screenshots are taken in a desktop browser with `isNativeAndroid` mocked. On a real Android device the reveal icon is hidden (via existing `display: none` in the mobile media query) and the share icon is fully visible in the 110px RESULT column. ## Test plan - `yarn lint && yarn typecheck && yarn test && yarn check:deps` — all pass (519 tests) - `yarn build && yarn build:web:standalone` — both build clean - New unit tests: `tests/infrastructure/web/browser_file_bytes.test.ts` (7 tests) — stash behaviour, Android write path, event dispatch, no anchor click on Android - Desktop path: existing `<a download>` behaviour is unchanged; all existing tests still pass Closes #186
forgejo_admin added 12 commits 2026-05-22 11:54:19 +00:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds @capacitor/filesystem ^7 as devDep, creates isNativeAndroid()
helper in src/infrastructure/web/platform.ts, and adds savedToDownloads,
shareFile, shareZip i18n keys (25 locales each) to strings.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(android): WRITE_EXTERNAL_STORAGE permission for Downloads (maxSdkVersion 28)
Some checks failed
CI / Lint, Typecheck & Unit Tests (pull_request) Failing after 9s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Has been skipped
CI / E2E (Web) (pull_request) Has been skipped
CI / E2E (Standalone single-file) (pull_request) Has been skipped
276fd9751e
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 12:07:14 +00:00
fix(android): zip share filename, error surfacing, dedup arrayBuffer, add tests
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 25s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 1m0s
CI / E2E (Standalone single-file) (pull_request) Successful in 4m29s
CI / E2E (Web) (pull_request) Successful in 7m12s
f9389e9cde
- BrowserFileBytes: add _filenameStash map; stashBytes() accepts optional
  filename arg; getStashedFilename() getter; clearStash() clears both maps
- web_api: triggerZipSave returns Uint8Array so the trigger lambda reuses
  already-computed bytes for the stash (eliminates second arrayBuffer() call)
- web_api: trigger lambda passes filename to stashBytes(ZIP_STASH_KEY, ...)
  so shareFile resolves the real zip filename instead of "__batch_zip__"
- web_api: shareFile uses getStashedFilename() as primary filename source,
  falls back to path-split only when no stored filename is present
- web_api: void batchOutput.finalize().catch() surfaces Filesystem.writeFile
  rejections via metascrub:download-error custom event
- FileTable: useEffect for metascrub:download-error shows toast on batch-zip
  save failure
- strings.json: remove spurious "pt" entry from savedToDownloads, shareFile,
  shareZip (kept pt-BR)
- tests: new stashBytes/getStashedFilename/clearStash coverage in
  browser_file_bytes.test.ts; new shareFile + notifyClearFiles describe
  blocks in web_api.test.ts (519 → 528 tests, all green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 12:51:02 +00:00
fix(android): use Directory.External (no permission needed) for file saves
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 31s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 41s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m34s
CI / E2E (Web) (pull_request) Successful in 3m7s
7c87c7ba40
Directory.ExternalStorage + Download/ path requires WRITE_EXTERNAL_STORAGE
at runtime on Android ≤9 (which we never requested) and is blocked by scoped
storage on Android 10+ regardless. Directory.External maps to the app-private
external dir (Android/data/com.metascrub.app/files/) which needs no permission
on any Android version.

- browser_file_bytes.ts / web_api.ts: Directory.External, no Download/ prefix
- AndroidManifest.xml: remove WRITE_EXTERNAL_STORAGE (no longer needed)
- strings.json: update savedToDownloads message to guide user to Share button
- Tests: update Directory mock from ExternalStorage → External

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 14:34:55 +00:00
fix(android): request permission + try public Downloads, widen RESULT column
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 32s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 45s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m40s
CI / E2E (Web) (pull_request) Successful in 3m31s
25b39bb375
- browser_file_bytes.ts / web_api.ts: call requestPermissions() then try
  Directory.ExternalStorage + Download/ (public Downloads, works on ≤9);
  catch EACCES and fall back to Directory.External (app-private, works on 10+)
- AndroidManifest.xml: restore WRITE_EXTERNAL_STORAGE maxSdkVersion=28
  (needed for the requestPermissions() flow on Android ≤9)
- file_table.css: widen mobile RESULT column 110→138px, reduce BEFORE/AFTER
  56→46px — this makes the share icon visible next to the result pill

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 14:48:57 +00:00
fix(android): use @capacitor/share with filesystem URI; surface errors
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 23s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 1m11s
CI / E2E (Standalone single-file) (pull_request) Successful in 6m5s
CI / E2E (Web) (pull_request) Successful in 7m50s
48739b9f6a
navigator.share({files:[blobFile]}) is unreliable in the Capacitor WebView —
the native share intent needs a real filesystem URI. Switching to
@capacitor/share.Share.share({files:[uri]}) which uses Android's native
share sheet with the URI returned from Filesystem.writeFile.

Also: any unexpected error (other than user cancellation) is now dispatched
as a metascrub:download-error event which FileTable surfaces via toast,
so failures are no longer silent.

- web_api.ts: import Share, use it on Android with the stashed URI; keep
  navigator.share fallback for desktop
- browser_file_bytes.ts: stash the uri returned by Filesystem.writeFile
  alongside bytes/filename; expose getStashedUri / stashUri
- triggerZipSave returns { bytes, uri } so the trigger lambda can stash both
- Tests: mock @capacitor/share and updated Filesystem.writeFile to return
  the WriteFileResult { uri } shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 15:21:44 +00:00
feat(android): MediaStore.Downloads plugin for true public Downloads writes
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 30s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 46s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m28s
CI / E2E (Web) (pull_request) Successful in 3m20s
e470b42c35
@capacitor/filesystem can't write to public Downloads on Android 10+ because
scoped storage blocks raw File writes through getExternalStoragePublicDirectory()
regardless of permissions. The documented Android replacement is the MediaStore
API, which @capacitor/filesystem does not expose.

This adds a small custom Capacitor plugin (SaveToDownloadsPlugin.java, ~80
lines) that:

  - Android 10+ (API 29+): uses MediaStore.Downloads.EXTERNAL_CONTENT_URI +
    ContentResolver.insert. No runtime permission needed.
  - Android ≤9: direct File write to Environment.DIRECTORY_DOWNLOADS via the
    WRITE_EXTERNAL_STORAGE manifest declaration (maxSdkVersion=28).

Result: cleaned files now actually appear in the user's public Downloads
folder on all Android versions, visible in the Files app's Downloads view
and the share-picker. The Share button still works (the MediaStore content://
URI is shareable via @capacitor/share).

Fallback path: if the native plugin call fails (e.g. stale APK before
recompile), @capacitor/filesystem app-private write still runs as a safety net.

- android/app/.../SaveToDownloadsPlugin.java: new plugin
- android/app/.../MainActivity.java: registerPlugin(SaveToDownloadsPlugin.class)
- src/infrastructure/web/save_to_downloads.ts: JS binding + MIME-type helper
- src/infrastructure/web/browser_file_bytes.ts: try SaveToDownloads, fall back
- src/infrastructure/web/web_api.ts: same for the zip path
- strings.json: revert savedToDownloads to "Saved to Downloads" (now accurate)
- tests: mock SaveToDownloads, update assertions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 16:03:40 +00:00
fix(android): drop @capacitor/share; share via own plugin (handles content://)
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 41s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 52s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m46s
CI / E2E (Web) (pull_request) Successful in 3m35s
a1944b262c
The upstream @capacitor/share plugin only accepts file:// URIs — its
isFileUrl() check rejects content:// outright and it auto-wraps file://
URIs in FileProvider (which we don't have configured). Both paths broke
sharing for us: MediaStore returns content:// URIs that get rejected,
and any file:// fallback fails because FileProvider isn't set up.

Cleanest fix: add a share() method to SaveToDownloadsPlugin that builds
the ACTION_SEND intent directly. Works with either URI scheme, sets
FLAG_GRANT_READ_URI_PERMISSION so the receiving app can read content URIs,
and uses FLAG_ACTIVITY_NEW_TASK so the chooser launches from plugin context.

- SaveToDownloadsPlugin.java: add native share() method
- save_to_downloads.ts: expose .share() on the JS binding
- web_api.ts: shareFile on Android calls SaveToDownloads.share, not Share
- package.json: drop @capacitor/share dependency
- Tests: mock SaveToDownloads.share

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 16:22:16 +00:00
feat(android): SAF (file picker) for save + FileProvider for share
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 29s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 43s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m37s
CI / E2E (Web) (pull_request) Successful in 3m28s
f9fb8cd148
Drops the auto-save / MediaStore.Downloads / WRITE_EXTERNAL_STORAGE
machinery in favor of user-controlled save and share:

  Save → Intent.ACTION_CREATE_DOCUMENT (Storage Access Framework).
         System file picker opens; user chooses destination folder
         AND filename. Plugin streams the cache file into the
         user-chosen URI. No permissions, no manifest declarations.

  Share → Intent.ACTION_SEND. Cache file → FileProvider URI →
          share sheet. User picks the receiving app. Works for
          both file:// (FileProvider-wrapped) and content:// URIs
          with FLAG_GRANT_READ_URI_PERMISSION.

  Cache → cleaned bytes written to Directory.Cache the moment a
          file finishes processing. Both Save and Share read from
          this cache file. Cleared on "Clean more".

Why the change:
  - MediaStore writes worked on Android 10+ but were inflexible
    (always lands in Downloads, no rename option) and broke share
    (content:// URIs were rejected by @capacitor/share).
  - SAF gives the user control over WHERE and WHAT NAME, which
    matches modern Android UX expectations and avoids the
    permission/scoped-storage minefield entirely.

Changes:
  - SaveToDownloadsPlugin.java: replace save() with saveAs() that
    uses startActivityForResult + ActivityCallback for the file
    picker. share() now uses FileProvider for file:// URIs.
  - AndroidManifest.xml: add FileProvider entry (authority
    com.metascrub.app.fileprovider). Drop WRITE_EXTERNAL_STORAGE.
  - res/xml/file_paths.xml: new — exposes cache + files dirs.
  - save_to_downloads.ts: saveAs/share TypeScript binding.
  - browser_file_bytes.ts: Android branch writes to
    Filesystem.Cache + stashes URI. clearStash() also deletes the
    cache files (best-effort).
  - web_api.ts: shareFile → SaveToDownloads.share. New saveFileAs
    → SaveToDownloads.saveAs. Zip path mirrors single-file.
  - FileRow.tsx + FileTable.tsx: Save icon (new) next to Share
    icon; "Save zip" button next to "Share zip".
  - SaveIcon.tsx: new icon component.
  - file_table.css: .file-table__save styles; widen mobile RESULT
    column from 138px to 160px to fit two icons.
  - strings.json: saveFile + saveZip keys (25 locales); update
    savedToDownloads to "File saved" (location is user-chosen now).
  - Tests updated for the new flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 17:26:29 +00:00
fix(ui): readable BEFORE/AFTER on Android + breathing room in header/status bar
Some checks failed
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 29s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 48s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m36s
CI / E2E (Web) (pull_request) Failing after 3m29s
2b926bb6d5
- file_table.css: rebalance mobile grid — TYPE 56→44, BEFORE/AFTER 40→48,
  RESULT 160→156. "30 KB" / "12.3 MB" now fit without ellipsis.
- file_table.css: header padding-y 4px → 16px (4x). The default was way
  too cramped vertically.
- status_bar.css: min-height 36 → 56, switch to padding-based vertical
  spacing. Standard Android bottom-bar height feels right.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 17:39:33 +00:00
fix(ui): outer app padding above header + below footer, widen size columns
Some checks failed
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 30s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 43s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m30s
CI / E2E (Web) (pull_request) Failing after 3m28s
c0921ab645
Previous attempt added vertical padding INSIDE the header and status bar,
which made them taller but didn't add the breathing room the user actually
wanted — distance between the screen edges and the app's content boundary.

Now:
- app.css: padding-top/bottom = safe-area-inset + 20px. The file table
  header is 20px below where the system clock ends; the status bar is
  20px above the bottom edge / gesture-nav pill.
- file_table.css (mobile): revert header padding-y to --ec-space-2 (8px).
- status_bar.css: revert to height: 36px and 0 vertical padding.

Also bump BEFORE/AFTER columns 48 → 56px so "12.3 MB" / "30 KB" fit
without ellipsis. Reclaimed 16px from TYPE (44→40) and RESULT (156→144);
NAME column width unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 17:52:46 +00:00
review: fix InputStream leak, dep placement, dead-code, test coverage
Some checks failed
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 30s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 47s
CI / E2E (Standalone single-file) (pull_request) Successful in 2m20s
CI / E2E (Web) (pull_request) Failing after 3m33s
57500884c5
Pre-merge review feedback (severity > 35):

- SaveToDownloadsPlugin.java: try-with-resources around both InputStream
  and OutputStream in saveAsResult. Previously, if ContentResolver
  .openOutputStream() threw, the FileInputStream was already open and
  would leak its FD. Also explicitly null-check sourcePath.
- package.json: move @capacitor/filesystem from devDependencies to
  dependencies — it's a runtime plugin that participates in `cap sync`
  and the spec called this out. Other @capacitor/* packages stay in
  devDependencies because they're build tools (cli, etc.); core stays
  because it's bundled transitively.
- web_api.ts: remove unreachable desktop branch from saveFileAs. The
  Save button is Android-gated in the UI and the URI stash is only
  populated on the Android write() path, so the desktop code was dead.
  Simplified to a single early-return + Android-only SAF flow.
- tests/web_api.test.ts: add saveFileAs test coverage. Three new tests
  cover the empty-stash early return, the desktop no-op contract, and
  that SaveToDownloads.saveAs is not called when no URI is stashed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin force-pushed fix/android-downloads from 57500884c5 to f52f4bbcee 2026-05-22 18:07:07 +00:00 Compare
forgejo_admin added 1 commit 2026-05-22 18:13:18 +00:00
fix(ui): shrink Save+Share icons + widen RESULT — both icons now visible
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 31s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 48s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m41s
CI / E2E (Web) (pull_request) Successful in 3m31s
ce5bbed929
Previous RESULT=128 fit Save but clipped Share. Bumped RESULT to 144 and
shrunk both icons to 20x20 with 2px margin on mobile (24x24/4px on desktop
still). Math: Arabic pill (~90px) + 22px Save + 22px Share = ~134px, fits
in 144px column with slack.

Cost: NAME column shrinks from 58→42 on a 390px viewport. Acceptable —
short filenames still readable, longer ones rely on ellipsis as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin merged commit c249ec86ea into master 2026-05-22 18:19:11 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: forgejo_admin/exifcleaner-web#189
No description provided.