Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
10 KiB
Give dufs-cloud real latency feedback on its slow operations
findings (4, ranked by value over effort)
- No cancel on the unbounded index walk or the ZIP build — the real usability failure, and worth more than any animation.
- Slow operations report one static string — the walk and the zip both know real counts and discard them.
- No
prefers-reduced-motionblock anywhere in 806 lines of CSS — a straight accessibility gap. - Long-press multi-select has no haptic — the canonical place for one.
what
dufs-cloud has genuinely slow operations and tells the user almost nothing while they run. Verified 2026-08-16:
- The recursive cloud index walk lists every directory in the cloud,
unbounded. Web shows one static string
"Indexing cloud…"; Flutter shows one indeterminateCircularProgressIndicator. No count, no current path, no cancel on either side. - The multi-file ZIP build downloads every selected file's bytes into memory
and then zips them, with
setBusy('Preparing N item(s)…')— a single static string for the entire operation. NeitherbuildSelectionZipsignature even accepts a progress callback. - Web's entire 806-line
index.csscontains exactly one transition (.tile-actions { transition: opacity 0.12s }~:539-544), zero@keyframes, and noprefers-reduced-motionblock at all. - Flutter side has zero animations and zero haptics — notably
browser_screen.dart(~:95) enters multi-select on long-press with noHapticFeedback.selectionClick(), the canonical place for one.
This is the article's micro-animation point in its most defensible form: replace dead latency with honest, informative feedback. Nothing here needs a manufactured reward — the operations are genuinely slow and the progress is genuinely knowable.
where
Repo: ~/src/dufs-cloud. Web: web/. Flutter: app/ (package dufs_client).
First step — bump both dependencies to pick up prompt 01's motion tokens:
web/package.json:"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.3.1&path:/web_ui"→ bump toweb_ui-v0.3.2(or whatever tag prompt 01 printed). pnpm only — npm cannot install a monorepo subdirectory and will lock the repo.app/pubspec.yaml: bumpdesign_systemto the tag prompt 01 cut. Prompt 07 must have run first — it is what adds this dependency at all.
Slow operations (the highest-value targets):
- Index walk — web
web/src/hooks/use-cloud-index.ts(walkInto~:36-43, root walk ~:56-63,loadingderived ~:113); Flutterapp/lib/services/cloud_index.dart(buildCloudIndex~:25,_walk~:31-41), driven byapp/lib/screens/browser_screen.dart_ensureIndex()~:184-194. - ZIP build — web
web/src/lib/download.ts(gatherFiles~:24,buildSelectionZip~:37-47,saveBytes~:55; encoderweb/src/lib/zip.tszipStore~:32), triggered fromweb/src/components/gallery.tsx~:307; Flutterapp/lib/services/download_zip.dart(gatherFiles~:15,buildSelectionZip~:29-37). - Upload — web
gallery.tsx~:186; Flutterapp/lib/services/dufs_client.dartupload()~:73-81 (a plain_http.putwith the full body — not aStreamedRequest, so byte progress is not currently observable), called frombrowser_screen.dart~:339. - Bulk mutations, all single static strings in
gallery.tsx: move ~:211/:271, delete ~:229/:290 (the bulk delete loops per item and reports only a final failure count), rename ~:243, create dir ~:257. - Directory listing — web
web/src/hooks/use-listing.ts~:31-72; Flutterbrowser_screen.dart~:120/:140/:149/:155.
Other surfaces:
web/src/index.css(806 lines;.busystyled ~:87 — static text, no shimmer).web/src/components/filter-bar.tsx~:75-83 — the search input firesonFilteron every keystroke with no debounce.web/src/components/gallery.tsx~:454{loading && <p className="muted">Loading…</p>}, ~:355 thebusyspan;folder-picker.tsx~:97.- Flutter spinners:
browser_screen.dart~:717, ~:732,RefreshIndicator~:748 (the one built-in motion affordance), plustext_editor_screen.dart~:101/:80,audio_screen.dart~:104,video_screen.dart~:169,pdf_screen.dart~:184,app/lib/widgets/folder_picker.dart~:92. - Settings:
app/lib/screens/settings_screen.dart(93 lines — room for a toggle), storeapp/lib/services/settings.dart.
must
Step 1 — motion + honest progress (the valuable half).
- Make the index walk report real progress: directories walked, files found,
and ideally the current path. The walk already knows these numbers — it just
discards them. Thread a progress callback through
buildCloudIndex/walkInto. - Make the ZIP build report per-file progress (
3 of 47). BothbuildSelectionZipsignatures need a progress callback added; today neither accepts one. - Add a cancel to both. A long unbounded operation with no way out is the actual usability failure here, and it is more valuable than any animation.
- Add
HapticFeedback.selectionClick()on the Flutter long-press to multi-select (browser_screen.dart~:95). No dependency needed (flutter/services.dart); add<uses-permission android:name="android.permission.VIBRATE"/>to the manifest if absent. - Add a
@media (prefers-reduced-motion: reduce)block toweb/src/index.css. There is none today — this is a straight accessibility gap, not a nicety. Flutter side: honourMediaQuery.of(context).disableAnimations. - Use the shared motion tokens for any transition you add. Replace the inline
0.12satindex.css~:539-544 with the token while you are there. - Debounce the search input (
filter-bar.tsx~:75-83). Firing on every keystroke over a large index is the cause of jank, and no animation fixes that.
Step 2 — sound (separate, independently revertable).
- A completion cue for long operations only (index built, ZIP ready, upload finished) — not for navigation or selection. A file manager that chirps on every click is intolerable.
- Sound ships on, with an opt-out toggle in
app/lib/screens/settings_screen.dartbacked byapp/lib/services/settings.dart, and an equivalent on web. - Consider suppressing the cue when the operation finishes in under ~2s — a completion sound for something that felt instant is noise.
Both steps:
-
must not: fake progress. No indeterminate bar dressed up as determinate, no bar that advances on a timer, no "estimated" percentage that is really elapsed time. If a total is unknown, show the count that is known ("47 files found") rather than a fabricated fraction. This is the project's first test and the easiest place in the fleet to violate it.
-
must not: add a variable/random reward. There is no real pool here; any variance would be manufactured.
-
must not: change what the index actually contains, or filter/sort semantics (
web/src/lib/filter-sort.ts,app/lib/util/filter_sort.dartmirror each other — a change to one desyncs the port). -
must not: use npm in
web/. pnpm only. -
must not: let web and Flutter drift further apart. If you add a progress callback on one side, add the equivalent on the other, or state explicitly in the summary why not.
-
optional: skeleton screens for the directory listing instead of "Loading…". Genuinely useful for perceived latency, but lower value than cancel + real counts. Do it only after step 1 lands.
done
- Starting a cloud index shows a live, true count and can be cancelled — on both web and Flutter.
- A multi-file ZIP shows per-file progress and can be cancelled.
web/src/index.csshas aprefers-reduced-motionblock; with the OS setting on, transitions are suppressed.- Long-press multi-select produces a haptic on the phone.
cd ~/src/dufs-cloud/web && pnpm install && pnpm build && pnpm testpasses (use the repo's actual script names).cd ~/src/dufs-cloud/app && flutter analyze && flutter testclean.- No fabricated progress value anywhere in the diff.
verify
Both surfaces — this repo has two and they drift.
Web: run the dev server, open it in a real browser, and index a directory large enough to take several seconds. Confirm the count moves, the cancel works, and the numbers are real (cross-check the final count against the actual file count). A jsdom test cannot verify this — it has no layout and no perceived latency.
Flutter, on the phone:
adb devices
cd ~/src/dufs-cloud/app
flutter build apk --release
adb install -r build/app/outputs/flutter-apk/app-release.apk
Never adb uninstall/pm clear — the app holds a stored credential.
Then trigger a real index build and a real multi-file ZIP on the device and report what you saw. State the haptic as a physical observation.
read first
web/src/hooks/use-cloud-index.tsandapp/lib/services/cloud_index.dart— read both before changing either; they mirror each other deliberately.web/src/lib/download.tsandapp/lib/services/download_zip.dart— same.web/src/components/gallery.tsx— thesetBusycall sites; they are the inventory of every operation currently reduced to one static string.~/src/dufs-cloud/DESIGN_AUDIT_TODO.md— prior findings for this repo.~/src/utils/unified-design-system/motion.md— vocabulary from prompt 01.
context you would otherwise rediscover
- Prompt 07 must have run first. Until it does,
app/has nodesign_systemdependency and hand-restates the palette inapp/lib/ui/theme.dart. - Web tokens come from
@kuhyx/web-uiimported inweb/src/main.tsx;web/src/index.cssdeclares only--overlay. New tokens arrive only after the tag bump — this is the step that is easy to forget and silently produces "the variable doesn't exist". upload()uses a non-streaming_http.put, so real byte-level upload progress needs aStreamedRequestrefactor. That may be more than this prompt wants — if you skip it, say so explicitly and report per-file progress instead of per-byte rather than faking a byte percentage.- Media playback (
web/src/hooks/use-dash-player.ts,web/src/lib/dash-buffer.ts,app/lib/services/app_player.dart) has its own buffering states. Out of scope unless trivially adjacent.
REMOVE ME AFTER FINISH