diff --git a/dopamine-ux/00-INDEX.md b/dopamine-ux/00-INDEX.md
new file mode 100644
index 0000000..c69da77
--- /dev/null
+++ b/dopamine-ux/00-INDEX.md
@@ -0,0 +1,126 @@
+# Dopamine UX — session prompts
+
+Ten pasteable prompts derived from *"The Dopamine Architecture: How UX Designers
+Hold User Attention in 2026"* (london-post.co.uk), applied to kuhy's repos.
+
+Paste **one file's contents** into a fresh Claude session. Each is
+self-contained: it restates the findings it depends on, so no session needs the
+context of another.
+
+## The two tests — every change must pass both
+
+The article's four mechanics (variable rewards, micro-animations, sound design,
+near-miss) are in scope, aimed at **kuhy's own stated goals**. They are gated by
+two questions, not by an allow/deny list:
+
+1. **Is the displayed state true?** A progress ring at 93% is legitimate *iff*
+ you are actually at 93%. A streak is legitimate iff it counts real days.
+ Dopamine may come from framing, timing and salience — **never from a
+ fabricated number**.
+2. **Is the variance real?** A random pick from a real pool (next workout, next
+ task) is legitimate. Injected random bonus points to manufacture uncertainty
+ are not.
+
+### The load-bearing corollary
+
+**Reward the recording, never the number recorded.** In diet-guard the
+satisfying moment fires on *logging a meal*, not on hitting a calorie target —
+otherwise the app rewards eating less rather than recording honestly, and
+corrupts its own data. Same shape in workout_app (reward logging a workout, not
+beating a PB) and todo (reward capture, not completion count).
+
+## Running order
+
+`01` gates everything. `07` gates `08`. Everything else is independent.
+
+| # | File | Repo | Findings |
+|---|---|---|---|
+| 01 | `01-motion-tokens.md` | `~/utils` | 3 |
+| 02 | `02-structural-check.md` | `~/utils` | 2 |
+| 03 | `03-diet-guard.md` | `~/diet-guard` | 4 |
+| 04 | `04-screen-locker.md` | `~/screen-locker` | 3 |
+| 05 | `05-workout-app.md` | `~/screen-locker/stronglift_replacement/workout_app` | 4 |
+| 06 | `06-todo.md` | `~/todo` | 3 |
+| 07 | `07-dufs-theme.md` | `~/dufs-cloud` | 2 |
+| 08 | `08-dufs-motion.md` | `~/dufs-cloud` | 4 |
+| 09 | `09-wake-alarm.md` | `~/wake-alarm` | 2 |
+
+**27 ranked findings total.**
+
+### Dependency graph
+
+```
+01-motion-tokens ──┬─→ 03, 04, 05, 09 (transcribe tokens locally)
+ ├─→ 06 (bump design_system-v0.2.0)
+ └─→ 07 ─→ 08 (bump web_ui-v0.3.2)
+02-structural-check (independent; run after 01 so it has motion tokens to check)
+```
+
+## The top three findings fleet-wide
+
+Ranked by value-over-effort from the survey data:
+
+1. **screen-locker streak render** (prompt 04) — `snapshot.streak` is already
+ populated and no renderer reads it. Zero new state, zero conftest change,
+ zero new dependency.
+2. **diet-guard `_onLogMeal` feedback** (prompt 03) — the flagship case.
+ Dependency-free via `HapticFeedback` from `flutter/services.dart`.
+3. **workout_app finish-workout cue** (prompt 05) — `audioplayers`, `vibration`,
+ the `assets:` block and the VIBRATE permission are **already present**. Only
+ the call is missing.
+
+## Keeping these prompts honest
+
+Every path and symbol cited across these files is machine-checked:
+
+```
+python3 ~/utils/dopamine-ux/verify_prompts.py
+```
+
+Exit 0 means every cited path resolves and every cited symbol is still greppable
+in the file it is attributed to. **Re-run it after any refactor of the surveyed
+repos** — a prompt pointing at a renamed function is worse than no prompt, and a
+path check alone cannot catch that (which is why it greps symbols too).
+
+Verified 2026-08-16: 146 paths, 30 symbols, all passing.
+
+## Rules that apply to every prompt
+
+1. **Tag cuts and version bumps are explicit steps.** `todo` consumes
+ `design_system` at git ref `design_system-v0.1.0`; `dufs-cloud/web` consumes
+ `@kuhyx/web-ui` at `web_ui-v0.3.1`. Adding tokens to those packages does
+ nothing for consumers until a new tag is cut. Prompt 01 ends by cutting
+ `design_system-v0.2.0` and `web_ui-v0.3.2`; prompts 06 and 08 begin by
+ bumping to those literal refs.
+2. **Anchor on symbols, not line numbers.** Line numbers in these prompts are
+ "as of 2026-08-16" and will drift. The symbol name is the real anchor —
+ grep for it rather than trusting the line.
+3. **Reduce-motion is mandatory and is NOT the sound toggle.** Web:
+ `@media (prefers-reduced-motion: reduce)`. Flutter:
+ `MediaQuery.of(context).disableAnimations`. The OS motion preference is
+ honoured automatically; the sound opt-out is a separate user-facing toggle.
+4. **Motion and haptics first, sound second.** Haptics need no dependency;
+ sound needs a pubspec dep, an `assets:` block, and a settings toggle. Each
+ prompt stages them separately so a short session still lands the valuable
+ half, and sound stays independently revertable.
+5. **Sound ships on (opt-out), never in screen-locker enforcement paths.**
+6. **250-line cap** applies to code and prose alike (`~/utils/file_length`).
+
+## Source
+
+Article findings, verbatim from the piece:
+
+- **Variable reward schedules** — unpredictable outcomes produce stronger
+ dopaminergic activity than guaranteed ones. Duolingo streaks, feed ordering,
+ pull-to-refresh.
+- **Micro-animations** — "a button that bounces on press, a like counter that
+ ticks upward with a slight delay". Replaces latency with feedback, so waiting
+ reads as responsiveness rather than frustration.
+- **Sound design** — "operates below the threshold of conscious attention";
+ the article's judgement is that it is more powerful than visual design and
+ gets less critical attention.
+- **Near-miss effects** — a fitness ring at ninety-three per cent creates
+ discomfort that drives re-engagement, and works *even when the user is
+ explicitly told the mechanism*.
+- Core claim: **dopamine is released in anticipation of a reward, not on
+ receipt. The uncertainty is the trigger.**
diff --git a/dopamine-ux/01-motion-tokens.md b/dopamine-ux/01-motion-tokens.md
new file mode 100644
index 0000000..06671c0
--- /dev/null
+++ b/dopamine-ux/01-motion-tokens.md
@@ -0,0 +1,138 @@
+Add motion, haptic and sound tokens to the unified design system
+
+## findings (3, ranked by value over effort)
+
+1. **No motion token exists in any stack** — greenfield. Every app that wants an
+ animation currently invents its own number, and one already has.
+2. **No haptic vocabulary exists** — the highest-value gap, because
+ `HapticFeedback` needs no dependency, so three of four apps can adopt it
+ the day this lands.
+3. **`tokens.md` is 260 lines against a 250 cap** — it cannot absorb a new
+ section, so motion needs a sibling file.
+
+## what
+
+The shared design system has colour, spacing, radius, type and shadow tokens —
+and **no motion tokens at all**. Verified 2026-08-16: no `Duration`, `Curve`,
+`--duration-*`, `--ease-*`, or haptic constant exists in any stack copy. There is
+not even a prose position that motion is out of scope.
+
+Consequence across kuhy's Flutter fleet: **exactly one animation exists in four
+apps** — a lone `AnimatedContainer(duration: Duration(milliseconds: 200))` with an
+inline magic number. Every other transition is a Flutter/browser default.
+
+Create the motion token layer so the per-app prompts (03–09) have something
+canonical to consume instead of each inventing durations.
+
+## where
+
+Repo: `~/utils` (git repo, has CI + the file-length gate wired).
+
+**Create** `~/utils/unified-design-system/motion.md` — the prose freeze.
+
+**Do NOT append to `~/utils/unified-design-system/tokens.md`.** It is 260 lines
+and the cap is 250, so it already fails the gate; adding to it makes that worse.
+`screen-size.md` was split out of it for exactly this reason — follow that
+precedent and add a pointer line in `tokens.md` only if you can do so without
+growing it (i.e. by shortening another line).
+
+Then mirror the tokens into the three code stacks:
+
+| Stack | File | Form |
+|---|---|---|
+| Flutter/Dart | `~/utils/design_system/lib/src/tokens.dart` | `class AppDuration` / `class AppCurve` |
+| web/React | `~/utils/web_ui/src/tokens.css` | `--duration-*`, `--ease-*` custom props |
+| Python/Tkinter | `~/utils/gatelock/gatelock/_window.py` (`class LockConfig`) | int ms fields — **only if meaningful**; see `must not` |
+
+There are **three** code stacks plus the prose freeze. Do not go hunting for a
+fourth — it does not exist.
+
+## must
+
+- Define a **small** scale. Recommended, but use judgement:
+ `instant 0ms · fast 120ms · base 200ms · slow 320ms`, and easing
+ `standard (0.2, 0, 0, 1)` · `decelerate (0, 0, 0, 1)` · `accelerate (0.3, 0, 1, 1)`.
+ Justify each value in `motion.md` in one line — the file is a freeze, so the
+ reasoning has to survive without you.
+- Include a **haptic** vocabulary mapped to Flutter's `HapticFeedback` API:
+ which semantic event gets `selectionClick` vs `lightImpact` vs `mediumImpact`.
+ This is the highest-value part — `HapticFeedback` needs no dependency, so
+ three of four apps can adopt it immediately.
+- Include a **sound** vocabulary: named semantic cues (`confirm`, `complete`,
+ `error`) with the rule that sound ships **on** (opt-out) per app, and that
+ each app owns its own asset files.
+- State the reduced-motion contract in `motion.md`: web uses
+ `@media (prefers-reduced-motion: reduce)`, Flutter uses
+ `MediaQuery.of(context).disableAnimations`. Motion tokens collapse to `0ms`
+ under it; **haptics and sound are unaffected** (they are not motion).
+- Keep every file at or under **250 lines** (`~/utils/file_length/config.py`,
+ `MAX_LINES = 250`). `.md` and `.py` are capped; `.css` is not.
+- **Cut tags at the end** — this is the step that makes the work reach consumers:
+ - `design_system-v0.2.0` (consumed by `~/todo`, currently on `v0.1.0`)
+ - `web_ui-v0.3.2` (consumed by `~/dufs-cloud/web`, currently on `v0.3.1`)
+ Print both tag names at the end of the session; prompts 06, 07 and 08 need them.
+
+- must not: add motion values to the Tkinter `LockConfig` just for symmetry.
+ Tkinter has no animation framework. If a duration has no meaning there, mark
+ the row `n/a (tk)` in `motion.md` and leave the Python alone. An honest `n/a`
+ beats a dead constant.
+- must not: express any motion token as a hex-looking value. The existing
+ `palette_check.py` parses `#RRGGBB` and would either ignore it or trip on it.
+- must not: touch any colour token, or add a colour alongside the motion work.
+ That *would* break `palette_check.py`'s completeness half.
+- must not: add `# noqa` or `type: ignore` anywhere.
+
+- optional: a `prefers-reduced-motion` helper in
+ `~/utils/design_system/lib/src/` and/or `web_ui/src/` so consumers get the
+ collapse behaviour without reimplementing it. Nice, not required.
+
+## done
+
+All four hold:
+
+1. `~/utils/unified-design-system/motion.md` exists, is ≤250 lines, and defines
+ duration, easing, haptic and sound vocabularies with a one-line rationale each.
+2. `python3 ~/utils/file_length/check.py` (or
+ `~/utils/scripts/check_file_length.sh --all`) reports **no new** violations.
+ `tokens.md` (260), `nielsen-audit.md` (258) and `README.md` (257) are
+ pre-existing failures — do not fix them here, but do not add a fourth.
+3. `python3 ~/utils/unified-design-system/scripts/palette_check.py` still exits 0.
+4. Both tags exist: `git -C ~/utils tag | grep -E 'design_system-v0.2.0|web_ui-v0.3.2'`
+ prints two lines.
+
+## verify
+
+Desktop, any way you like — this prompt ships no UI. Run the two gate commands in
+`done` and paste their output. Then confirm the token names you chose are what
+prompts 03–09 will reference by printing the Dart class and the CSS custom-prop
+block.
+
+## read first
+
+- `~/utils/unified-design-system/tokens.md` — the existing freeze; match its
+ table format and tone exactly. Note its length problem before editing.
+- `~/utils/unified-design-system/screen-size.md` — the precedent for a sibling
+ file split out of `tokens.md` (32 lines; short and self-contained).
+- `~/utils/design_system/lib/src/tokens.dart` — `AppPalette`, `AppSpacing`,
+ `AppRadius`, `AppTextSize`. Your `AppDuration`/`AppCurve` sit alongside these.
+- `~/utils/web_ui/src/tokens.css` — the full custom-prop list.
+- `~/utils/design_system/lib/src/feedback.dart` — `showToast`/`showError` and
+ `_show`. `confirm.dart` next to it **already imports
+ `package:flutter/services.dart`**, so `HapticFeedback` is one call away with no
+ new import. This is the natural home for a shared haptic helper.
+- `~/utils/unified-design-system/scripts/palette_check.py` — read the four
+ `parse_*` regexes to see why motion tokens are invisible to it. Prompt 02
+ builds the checker that covers them; **do not extend this script here.**
+
+## context you would otherwise rediscover
+
+- **Green CI does not mean no drift for these tokens.** `palette_check.py` only
+ matches `#RRGGBB`, so a `--duration-fast: 120ms` is invisible to every one of
+ its parsers. `palette_map.py:159` claims structural values are "checked by the
+ scale check" — **no such script exists**. Prompt 02 writes it.
+- `~/utils/gatelock/` is a package inside the `~/utils` monorepo, not a
+ standalone repo. It is consumed as a pinned git dependency.
+- Only `~/todo` depends on `design_system`; diet-guard, workout_app and
+ wake_alarm hand-transcribe the token values into local `lib/ui/theme.dart`
+ copies. So tokens added here reach exactly one app automatically — the others
+ are handled per-prompt.
diff --git a/dopamine-ux/02-structural-check.md b/dopamine-ux/02-structural-check.md
new file mode 100644
index 0000000..9a88d91
--- /dev/null
+++ b/dopamine-ux/02-structural-check.md
@@ -0,0 +1,137 @@
+Add a structural-token drift checker to CI
+
+## findings (2, ranked by value over effort)
+
+1. **Spacing, radius and type have never been drift-checked** — `palette_check.py`
+ parses hex only, so three whole token categories can diverge across stacks
+ while CI stays green. Motion inherits the same hole.
+2. **`palette_map.py` points at a "scale check" that does not exist** — a
+ comment describing a guarantee nobody wrote.
+
+## what
+
+`palette_check.py` guarantees the colour palette agrees across all stack copies.
+It does **not** check anything else — verified 2026-08-16 by reading its four
+parsers, every one of which requires a `#RRGGBB` value:
+
+```python
+parse_css : r"(--[a-z0-9-]+)\s*:\s*(#[0-9A-Fa-f]{6})\s*;"
+parse_dart: r"static const Color (\w+) = Color\(0x[fF]{2}([0-9A-Fa-f]{6})\)"
+parse_tk : r'^\s{4}(\w+):\s*str\s*=\s*"(#[0-9A-Fa-f]{6})"'
+parse_md : r"^\|\s*`([a-z0-9-]+)`\s*\|\s*`(#[0-9A-Fa-f]{6})`"
+```
+
+So **spacing, radius and type have been silently unprotected all along**, and the
+motion tokens added by prompt 01 inherit the same hole. A `--duration-fast: 120ms`
+is invisible to every parser: CI passes while the stacks drift apart.
+
+Worse, `scripts/palette_map.py:159` claims structural values are "checked by the
+scale check" — **no such script exists anywhere in `~/utils`**. That comment
+describes a check that was never written. This prompt writes it.
+
+## where
+
+Repo: `~/utils`.
+
+**Create** `~/utils/unified-design-system/scripts/structural_check.py`.
+
+**Edit** `~/utils/.github/workflows/palette-drift.yml` — add a step invoking it.
+The workflow deliberately has no `paths:` filter; keep it that way.
+
+**Fix** the stale comment at `~/utils/unified-design-system/scripts/palette_map.py`
+(~:159) to name the real script.
+
+Sources to compare (three code stacks + the prose freeze):
+
+| Role | Path |
+|---|---|
+| prose freeze | `~/utils/unified-design-system/tokens.md` (spacing/radius/type) |
+| motion freeze | `~/utils/unified-design-system/motion.md` (**created by prompt 01** — run it first) |
+| web/React | `~/utils/web_ui/src/tokens.css` |
+| Flutter/Dart | `~/utils/design_system/lib/src/tokens.dart` |
+| Python/Tkinter | `~/utils/gatelock/gatelock/_window.py` (`class LockConfig`) |
+
+## must
+
+- Cover **motion** (durations, easing) **and** the already-unprotected
+ **spacing, radius and type** scales, in one checker.
+- Mirror `palette_check.py`'s two-halves design, because it is the part that
+ actually works:
+ 1. **Agreement** — for each mapped token, resolve it in every non-`n/a` stack
+ and fail if values disagree.
+ 2. **Completeness, bidirectional** — every structural value parsed out of any
+ source must be mapped, or explicitly exempted. This is what catches
+ "added it to one stack, forgot the others".
+- Support an explicit **`n/a` per stack**, and require a reason string. Tkinter
+ has no animation framework; a duration legitimately does not exist there. An
+ honest `n/a` with a reason is correct — a fabricated constant is not.
+- **Adjudicate with exit codes.** Exit non-zero on drift with a message naming
+ the token and the disagreeing files. No warnings-only mode.
+- Keep every file ≤ **250 lines** (`~/utils/file_length/config.py`). Note
+ `palette_check.py` is 231 and `palette_map.py` is 162 — that pair was split
+ precisely to stay under the cap. **Split yours the same way from the start**
+ (`structural_check.py` + `structural_map.py`) rather than discovering the
+ problem at 251 lines.
+- Ruff-clean under the repo's config. No `# noqa`, no `type: ignore`.
+
+- **Expect it to fail on first run.** Spacing/radius/type have never been
+ compared; there may be real pre-existing drift. That is the point of writing
+ it. Fix whatever it surfaces, in the stack copy that is wrong, and say in the
+ session summary what drifted.
+- must not: weaken the checker, add an exemption, or narrow its scope to make it
+ green. If a token genuinely differs per stack for a good reason, encode it as
+ an `n/a`/variant **with the reason recorded**, not as a silent skip.
+- must not: modify `palette_check.py`. It works; it is simply colour-only. The
+ two scripts run side by side.
+- must not: change any token *value* to resolve a disagreement without saying so
+ explicitly — that is a design change, and `tokens.md` is a freeze. Surface it
+ and let kuhy decide which value is canonical.
+
+- optional: teach it the shadow tier too, if that falls out cheaply.
+
+## done
+
+1. `python3 ~/utils/unified-design-system/scripts/structural_check.py` exits 0,
+ and exits non-zero when you deliberately break one stack copy (**test both
+ directions** — a checker that never fails is not a checker).
+2. It is wired into `~/utils/.github/workflows/palette-drift.yml` and the
+ workflow passes.
+3. `palette_map.py`'s "scale check" comment names the real script.
+4. `~/utils/scripts/check_file_length.sh --all` reports no new violations.
+5. The session summary states what drift, if any, the first run found.
+
+## verify
+
+Desktop. Run the script, paste the output. Then prove it fails: change one
+spacing or duration value in **one** stack copy, re-run, paste the non-zero exit
+and the message, and revert. Both halves of that demonstration are required —
+the passing run alone proves nothing.
+
+## read first
+
+- `~/utils/unified-design-system/scripts/palette_check.py` — the design to
+ mirror. Read `NON_COLOUR_CSS`: it is the regex that currently *excludes*
+ spacing/radius/type/shadow from the colour check, and therefore the precise
+ list of what you are now covering.
+- `~/utils/unified-design-system/scripts/palette_map.py` — the `PALETTE` tuple of
+ `Token(canonical, md, css, dart, tk, why)` rows. Your map wants the same shape,
+ including the `why` field.
+- `~/utils/.github/workflows/palette-drift.yml` — how `palette_check.py` and
+ `ramp_check.py` are invoked; add your step alongside.
+- `~/utils/unified-design-system/motion.md` — what prompt 01 created.
+- `~/utils/unified-design-system/tokens.md` — the spacing/radius/type tables.
+ **Do not edit it**; it is 260 lines and already over the cap.
+
+## context you would otherwise rediscover
+
+- The four "stack copies" are really **three code stacks plus a prose freeze**.
+ There is no fourth code stack; do not hunt for one.
+- `~/dufs-cloud/app/lib/ui/theme.dart` is a **fifth, uncompared copy** of the
+ palette (the Flutter app has no `design_system` dependency and hand-restates
+ every hex). It is out of scope here — prompt 07 wires it up properly. Do not
+ add it as a source to this checker; wait until 07 has removed it.
+- Three Flutter apps (diet-guard, workout_app, wake_alarm) similarly
+ hand-transcribe tokens into local `lib/ui/theme.dart` files. Those are
+ deliberately outside the checker's scope for now — bringing them in is a much
+ larger job than this prompt.
+- `~/utils/gatelock/` is a package inside this monorepo, not a separate repo.
diff --git a/dopamine-ux/03-diet-guard.md b/dopamine-ux/03-diet-guard.md
new file mode 100644
index 0000000..005f862
--- /dev/null
+++ b/dopamine-ux/03-diet-guard.md
@@ -0,0 +1,168 @@
+Make logging a meal feel satisfying in diet_guard_app
+
+## findings (4, ranked by value over effort)
+
+1. **`_onLogMeal()` gives zero feedback** — no haptic, no sound, no animation, no
+ confirmation. The flagship gap. Fix needs no new dependency.
+2. **Streak text is inert** — "Adherence streak: N days" is a plain `Text` that
+ blinks to a new value. Real data, no salience.
+3. **No VIBRATE permission** — blocks haptics until added to the manifest.
+4. **No audio capability at all** — no package, no `assets:` block. Step 2 only.
+
+## what
+
+Logging a meal currently produces **no feedback whatsoever**. `_onLogMeal()` in
+`lib/screens/log_meal_screen.dart` writes the meal, syncs it, clears the fields,
+and calls `setState(() { _progress = buildTodayProgress(log); })`. The progress
+card silently appears. No SnackBar, no haptic, no sound, no animation.
+
+That is the whole problem. In kuhy's words: *"noting what you ate should be
+SATISFYING and ADDICTING so you WANT TO ACTUALLY note down what you ate."* The
+data quality of the entire app depends on the logging habit surviving the days
+you don't feel like it, and right now the act of logging gives nothing back.
+
+Make the moment of logging land: an immediate haptic, a real confirmation, and
+the streak/progress reacting visibly rather than blinking into existence.
+
+## where
+
+Repo: `~/diet-guard`. Flutter app: `~/diet-guard/app` (package `diet_guard_app`).
+
+Primary:
+- `app/lib/screens/log_meal_screen.dart` — `_onLogMeal()` (line 122-168 as of
+ 2026-08-16; **grep the symbol, the line will drift**). The `setState` that
+ silently swaps in the progress card is at ~:164-166. Card rendered at ~:240.
+- `app/lib/widgets/today_progress_card.dart` — `TodayProgress` model (~:15),
+ `TodayProgressCard` (~:64). Consumed/budget kcal ~:100, P/C/F macro row ~:122,
+ **"Adherence streak: N days"** ~:129-130 — all plain `Text`, no ring, no bar.
+- `app/lib/widgets/streak_summary_row.dart` — `StreakSummaryRow`: logging streak,
+ adherence streak, YTD tally. Also text-only.
+- `app/lib/screens/log_meal_progress.dart` — `buildTodayProgress(DayLog)` (~:18),
+ `adherenceStreak(...)` (~:31).
+
+Theme (local hand-copy, **no `design_system` dependency**):
+- `app/lib/ui/theme.dart` — `buildAppTheme()` ~:12, `AppStatusColors`
+ ThemeExtension ~:89, re-declared `AppSpacing` ~:124, `AppRadius` ~:146,
+ `AppTextSize`, plus an `AppWidth` class the shared package does not have.
+
+Settings (for the sound opt-out in step 2):
+- `app/lib/screens/settings_screen.dart` — body is a `ListView` inside
+ `Center > ConstrainedBox(maxWidth: AppWidth.prose)` at ~:161-165.
+ **No `SwitchListTile` exists anywhere in this app** — you are adding the first.
+- `app/lib/services/app_settings_service.dart` — the store. **Not
+ SharedPreferences**: a singleton over a `DocumentStore` writing JSON to
+ `app_settings.json` (~:26). `init()` ~:49, `_load()` ~:95-112, setters funnel
+ through `_persist`/`_writeToDisk` ~:147-165 (read the comment at ~:153-155 —
+ `_writeToDisk` is the single write path every setter must use).
+ `resetForTesting`/`initForTesting` at ~:66/:74.
+
+## must
+
+**Step 1 — motion + haptics (no new dependency; land this first).**
+
+- On successful meal log, fire `HapticFeedback.mediumImpact()` (or the token
+ vocabulary's "complete" cue if prompt 01 defined one). `HapticFeedback` comes
+ from `package:flutter/services.dart` — **no pubspec change needed**. Add
+ `` to
+ `app/android/app/src/main/AndroidManifest.xml`; it is currently absent.
+- Fire it **immediately on the tap**, before the write/sync completes. The
+ article's point about micro-animations is that they replace latency with
+ feedback; a haptic that waits for a network round-trip defeats it.
+- Animate the progress card's arrival and the numbers' change rather than
+ snapping: the kcal figure and the streak count should visibly move to their new
+ value. Use the motion tokens from prompt 01.
+- Show an explicit confirmation of what was recorded — the user should not have
+ to infer success from a card appearing.
+- Honour `MediaQuery.of(context).disableAnimations`: collapse durations to zero.
+ Haptics still fire (they are not motion).
+
+**Step 2 — sound (separate, independently revertable).**
+
+- Add an audio dependency and an `assets:` block to `app/pubspec.yaml` (neither
+ exists today), plus a short confirmation cue asset.
+- Sound ships **on**, with an opt-out `SwitchListTile` in `settings_screen.dart`,
+ backed by a new bool in `app_settings_service.dart` following the existing
+ field pattern: private field + static getter, parsed in `_load()`, written via
+ `_writeToDisk`.
+- The toggle controls **UI sound only**. Never route notification-channel
+ behaviour through it.
+
+**Both steps:**
+
+- must not: **reward the calorie number.** The satisfying moment fires on
+ *logging*, not on being under budget. Rewarding a low number teaches the user
+ to eat less rather than record honestly, and corrupts the app's own data. A
+ meal that blows the budget gets the same confirmation as one that doesn't.
+- must not: fabricate or round up any displayed value. The streak counts real
+ consecutive days; the progress bar reflects the true ratio. Framing, timing
+ and salience are fair game — the numbers are not.
+- must not: add a celebratory cue to *deleting* or editing a meal. Only the
+ record-creating act gets it.
+- must not: touch `~/diet-guard/diet_guard/` (the Python desktop side) — it has
+ its own streak logic and is out of scope here.
+- optional: a progress ring instead of the plain kcal text. The article's
+ near-miss point applies honestly here — a ring at 80% of budget is truthful
+ and more motivating than a number. Only if it stays accurate.
+
+## done
+
+1. Logging a meal on the phone produces a haptic within ~50ms of the tap, an
+ explicit confirmation, and a visible (not instant) update of the streak/progress.
+2. `cd ~/diet-guard/app && flutter analyze` is clean.
+3. `cd ~/diet-guard/app && flutter test` passes.
+4. With OS "remove animations" enabled, the screen still works and durations are
+ zero — verified by toggling it, not by reading the code.
+5. Step 2 only: the settings toggle flips, persists across an app restart, and
+ silences the cue.
+
+## verify
+
+**On the phone — this app is mobile-primary; desktop is not sufficient.**
+
+```
+adb devices # confirm 23181JEGR08034 is attached
+cd ~/diet-guard/app
+flutter build apk --release
+adb install -r build/app/outputs/flutter-apk/app-release.apk
+```
+
+Never `flutter install`, never `adb uninstall`, never `pm clear` — those wipe
+real logged food data. `adb install -r` preserves it.
+
+Then log a real meal on the device and report what you felt and saw. A haptic is
+a physical outcome: state what the phone did, do not claim it worked from code.
+If `com.kuhy.*` focus-mode kills the app on launch, the app needs a focus
+whitelist entry before it can be tested.
+
+## read first
+
+- `app/lib/screens/log_meal_screen.dart` — `_onLogMeal()`. Read the whole method
+ before editing; the sync call and the field-clearing both matter for where the
+ haptic goes.
+- `app/lib/services/app_settings_service.dart` — the `dailyKcalGoal` field
+ (~:34/:40) is the pattern to copy for a new bool. Note the `_writeToDisk`
+ comment.
+- `~/diet-guard/diet_guard/_calendar_view.py` — `streaks_text()` ~:136 and
+ `ytd_text()` ~:154. The Dart `streak_summary_row.dart` docstring says it mirrors
+ this formatting; keep them consistent if you change wording.
+- `~/utils/unified-design-system/motion.md` — the motion/haptic vocabulary from
+ prompt 01. **Prompt 01 must have run first.**
+
+## context you would otherwise rediscover
+
+- This app has **no `design_system` dependency** — `app/lib/ui/theme.dart` is a
+ hand-transcription of the shared tokens (values are byte-identical, names in
+ comments). Motion tokens from prompt 01 must be **transcribed into that local
+ theme file**, matching the existing convention. Do not add the dependency here;
+ that is a separate, larger refactor.
+- Capability baseline, verified 2026-08-16: **no audio package, no `assets:`
+ block, no `vibration` package, no VIBRATE permission.** The only existing
+ audio-adjacent thing is the `diet_guard_due_slot` notification channel
+ (`app/lib/services/notification_backend_io.dart` ~:17-18, :52-55) at
+ `Importance.high` with no explicit sound/vibration override — leave it alone.
+- The app already computes both a **logging streak** and an **adherence streak**
+ (`_daystatus.py:144`/`:153` on the Python side, mirrored in Dart). You are
+ making existing true data feel like something, not inventing a new metric.
+- diet-guard state lives under XDG (`~/.local/share/diet_guard`), unlike
+ screen-locker's in-repo JSON. Tests isolate it via
+ `~/diet-guard/diet_guard/tests/conftest.py:97` `_isolate_state`.
diff --git a/dopamine-ux/04-screen-locker.md b/dopamine-ux/04-screen-locker.md
new file mode 100644
index 0000000..9b57688
--- /dev/null
+++ b/dopamine-ux/04-screen-locker.md
@@ -0,0 +1,168 @@
+Surface the workout streak that screen-locker already tracks
+
+## findings (3, ranked by value over effort)
+
+1. **`snapshot.streak` is populated and no renderer reads it** — the cheapest
+ win in the whole project. Zero new state, zero conftest change, zero deps.
+2. **Week-transition reward strings are discarded into `_logger.info`** —
+ fully-formed celebratory sentences that never reach a user.
+3. **The success moment exists only on the lock screen**, for 1.5s. The status
+ window — the surface visited *voluntarily* — shows only enforcement.
+
+## what
+
+screen-locker computes a real workout streak and celebratory reward text, then
+throws almost all of it away. Verified 2026-08-16:
+
+- `StatusSnapshot` (`screen_locker/_status_types.py` ~:88-101) carries populated
+ `streak`, `bonus_hours_this_week` and `early_bird_extended` fields, filled by
+ `_status_data.py` ~:210-214. **Grep confirms no renderer reads them.**
+- `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at
+ ~:130-139) produces fully-formed celebratory sentences — and
+ `_startup_checks.py` ~:57-58 sends them to `_logger.info` and nowhere else.
+- The one success moment that exists is on the **lock screen**:
+ `_unlock_view.py` `unlock_screen()` (~:25) paints "Great job! 💪" and
+ `🔥 {streak}-week streak`, for `_UNLOCK_DELAY_MS = 1500` before routing away.
+
+So the surface the user visits **voluntarily** — the status window, opened from
+the tray icon — shows enforcement and budget depletion, and no evidence of
+success at all. This is the cheapest high-value change in the whole project:
+the data already exists, is already true, and needs no new state.
+
+## where
+
+Repo: `~/screen-locker`.
+
+Primary:
+- `screen_locker/_status_sections.py` — the section renderers used by the status
+ window (`_section_*` functions). **177 lines; has headroom.** Add
+ `_section_streak` here.
+- `screen_locker/status_view.py` — `StatusWindow`, `render()`. Call the new
+ section from here. ⚠️ **246 lines — only 4 left under the 250 cap.** Expect to
+ extract something, or add the call in a way that costs ~1 line.
+- `screen_locker/_status_types.py` — `StatusSnapshot` (~:88-101), the fields you
+ are rendering. Read-only for this task.
+- `screen_locker/_extra_benefits.py` (179 lines) — `process_week_transition()`
+ and its reward strings; `current_streak()` ~:154,
+ `weekly_shutdown_bonus_hours()` ~:159, `has_extended_early_bird()` ~:171.
+- `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are
+ currently discarded into a log call.
+- `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing
+ success moment, if you extend it.
+
+## must
+
+- **Render the already-populated fields.** Add `_section_streak` to
+ `_status_sections.py` showing the weekly streak, this week's bonus hours, and
+ the extended-early-bird state, and call it from `status_view.py`'s `render()`.
+ Zero new state, zero conftest change, zero new dependency.
+- **Stop discarding the reward strings.** `process_week_transition()` returns
+ human-readable celebratory sentences that currently only reach the log. Surface
+ them where the user will actually see them — the status window is the obvious
+ home; a milestone is worth more than a silent counter increment.
+- Match the existing positive-status styling rather than inventing one:
+ `_status_sections.py` ~:73-78 already renders `"{n} above the weekly minimum!"`
+ in success green, and uses `✓` marks at ~:36 and ~:57.
+- Keep the streak **true**. It counts real ISO weeks with ≥5 counted workouts
+ (`_weekly_check.py`: `WEEKLY_WORKOUT_MINIMUM = 5`, `COUNTED_WORKOUT_TYPES =
+ {phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or
+ "encourage" the number.
+
+- must not: **make any reward reduce enforcement.** This is recorded twice in the
+ repo as a deliberate decision:
+ > `_startup_checks.py` ~:88-93 — "there is no banked 'skip a workout' credit —
+ > that mechanic works against the goal of maximizing weekly workouts, so it was
+ > removed in favor of a shutdown-time-only reward"
+ > `_extra_benefits.py` ~:5-8 — "This never reduces enforcement … it only grants
+ > extra comfort time on top of a floor you still have to earn each day."
+
+ Every reward here is **display or comfort-time only**. Do not reintroduce a
+ banked skip, a streak freeze, a grace day, or any mechanic that lowers the
+ floor. A dopamine loop that lets you skip the workout defeats the app.
+- must not: add **sound** to any enforcement or lock path. screen-locker is a
+ self-restriction tool; a chime on lock is punishment audio, and a chime on
+ unlock trains the wrong association. This repo stays silent. (Sound is
+ opt-out-on elsewhere in the fleet; here it is simply absent.)
+- must not: add a write tool to the MCP server. `_mcp.py` ~:14-26 states it is
+ read-only by design. Adding `streak` to the existing `get_status` output is
+ fine; a new action tool violates a stated invariant.
+- must not: add `# noqa` (banned repo-wide) or a silent `except`
+ (`scripts/check_silent_failures.py` — every handler re-raises or logs at
+ warning or above).
+- must not: create a new on-disk state file unless genuinely unavoidable. If you
+ do, you **must** add it to `_ISOLATED_STATE` in
+ `screen_locker/tests/conftest.py` (~:134), listing *every* module that binds
+ the path at import time — a missed binding lets tests write to kuhy's real
+ state. Read the comment at ~:131-133 and the fixture docstring at ~:170-175.
+
+- optional: a daily streak alongside the weekly one. Note this **would** be new
+ state (the existing streak is consecutive-*weeks*), so it triggers the conftest
+ requirement above. diet-guard's consecutive-*day* streak
+ (`~/diet-guard/diet_guard/_daystatus.py` ~:144) is the precedent to mirror.
+
+## done
+
+1. Opening the status window (tray icon → click, or `screen-locker-status`) shows
+ the current streak, bonus hours and early-bird state — with the real values
+ from `extra_benefits_state.json`, not placeholders.
+2. A week-transition milestone reaches the UI instead of only `_logger.info`.
+3. `cd ~/screen-locker && python -m pytest` passes.
+4. `pre-commit run --files ` is clean — including the 250-line cap
+ (`scripts/check_file_length.py`) and the silent-failure check.
+5. `git diff` touches no enforcement logic: no change to lock decisions, weekly
+ minimums, or shutdown floors.
+
+## verify
+
+Desktop — this is a desktop Python app.
+
+```
+cd ~/screen-locker
+python -m screen_locker --status # CLI: confirm the values it prints
+screen-locker-status # the Tk window: confirm they render
+```
+
+Paste both outputs. The CLI already prints `streak`/`bonus_hours` (`_status.py`
+~:138-141), so it is your ground truth: the window must show the **same numbers**.
+If they disagree, the renderer is wrong, not the data.
+
+Current real state for comparison: `screen_locker/extra_benefits_state.json` and
+`screen_locker/workout_log.json` (12 days of entries, 2026-07-12 → 2026-08-15).
+
+## read first
+
+- `screen_locker/_status_sections.py` — every existing `_section_*`. Match their
+ signature and style; note the success-green precedent at ~:73-78.
+- `screen_locker/_status_types.py` ~:88-101 — the three fields you are rendering.
+- `screen_locker/_extra_benefits.py` — especially the module docstring ~:5-8
+ (the enforcement invariant) and the reward strings ~:130-139.
+- `screen_locker/_unlock_view.py` — the existing celebration, for tone.
+- `screen_locker/status_view.py` — check its line count **before** editing.
+- `~/screen-locker/CLAUDE.md` — repo rules (no silent failures, ruff `select=ALL`).
+
+## context you would otherwise rediscover
+
+- **`workout_log.json` is the single source of truth**, written only through
+ `_log_mixin.py` `write_signed_entry()` (~:82), HMAC-signed via
+ `gatelock.log_integrity`, deduped on `workout_id`. The Flutter app converges on
+ it via `_manual_push.py`. There is no second history — a streak computed from
+ it is authoritative.
+- All persistence is **JSON files, no sqlite**; paths declared in
+ `screen_locker/_constants.py`. The streak store is
+ `screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`,
+ `last_processed_iso_week`, `weekly_shutdown_bonus_hours`,
+ `extended_early_bird_iso_weeks`).
+- Thresholds live in `_extra_benefits.py`: `_MILESTONE_INTERVAL = 4` (4-week
+ milestone), `_BONUS_THRESHOLD = 5` (workouts per week).
+- **There is no config system** — no argparse, no env vars, no config file. Every
+ tunable is a module-level constant. An opt-out toggle has no existing home;
+ for a self-restriction tool, requiring a code edit to disable is arguably
+ correct, so prefer a constant in `_constants.py` over new state.
+- `~/gatelock` is **not** a repo — it is `~/utils/gatelock/gatelock/`, consumed as
+ a pinned git dep, and it holds no workout state.
+- Manual workouts were **not** removed: `_manual_workout.py` implements a
+ rate-limited, evidence-gated subsystem (budget 2 per 7 days, 10 per 30).
+- Three UI surfaces exist: the Tk status window (`status_view.py`), a GTK tray
+ icon at `~/.config/i3/scripts/screen_locker_tray.py` (outside the repo, shells
+ out to the CLI, refreshes every 60s), and the lock screen itself. No i3blocks
+ integration exists despite `format_summary_line` being described as one.
diff --git a/dopamine-ux/05-workout-app.md b/dopamine-ux/05-workout-app.md
new file mode 100644
index 0000000..0582116
--- /dev/null
+++ b/dopamine-ux/05-workout-app.md
@@ -0,0 +1,192 @@
+Close the feedback asymmetry in workout_app
+
+## findings (4, ranked by value over effort)
+
+1. **Finishing a workout gets no cue** — while the rest timer gets sound *and*
+ an 800ms vibration. The app celebrates waiting and ignores achieving. All
+ infrastructure is already present; only the call is missing.
+2. **Set completion has no haptic** — the app's most repeated action, silent.
+3. **The fleet's only animation is an inline magic number** —
+ `Duration(milliseconds: 200)` in `exercise_tile_rows.dart`.
+4. **No sound opt-out exists** — the SQLite settings table is ready, but no
+ public accessor and no `SwitchListTile` anywhere. Step 2 only.
+
+## what
+
+workout_app is the only app in kuhy's fleet that can already play a sound and
+vibrate — and it spends that capability on the wrong moment. Verified 2026-08-16:
+
+- **Rest-timer expiry** gets a full cue: `_playBreakEndCue()` in
+ `lib/screens/workout_screen_session.dart` (~:128-145) plays
+ `assets/sounds/break_end.mp3` and fires `Vibration.vibrate(duration: 800)`.
+- **Finishing an entire workout** gets nothing. `_persistFinishedWorkout()` in
+ `lib/screens/workout_screen_finish.dart` (~:99-108) ends by showing
+ `WorkoutSummaryDialog` with no sound, no haptic, no animation.
+- **Completing a set** — the core repeated action of the whole app — gets no
+ haptic either. The `GestureDetector` at `lib/widgets/exercise_tile_rows.dart`
+ (~:145) toggles state silently.
+
+So the app celebrates *waiting* and ignores *achieving*. Fix the asymmetry: the
+moments worth reinforcing are completing a set and finishing a workout.
+
+This app also contains the fleet's **only animation** — a lone
+`AnimatedContainer(duration: Duration(milliseconds: 200))` at
+`exercise_tile_rows.dart` ~:146-147, with an inline magic number. Replace that
+number with a motion token while you are in the file.
+
+## where
+
+Repo: `~/screen-locker`. App: `~/screen-locker/stronglift_replacement/workout_app`
+(a Flutter app nested inside the screen-locker repo; that directory contains **no
+Python** — only the app and two design docs).
+
+Primary:
+- `lib/widgets/exercise_tile_rows.dart` — set-completion `GestureDetector` ~:145,
+ the `AnimatedContainer` ~:146-147.
+- `lib/screens/workout_screen_finish.dart` — `_persistFinishedWorkout()` ~:99-108.
+- `lib/widgets/workout_summary_dialog.dart` — `WorkoutSummaryDialog` ~:10.
+- `lib/screens/workout_screen_session.dart` — `_playBreakEndCue()` ~:128-145, the
+ existing cue to model the new ones on. Note its `.catchError` only
+ `debugPrint`s (~:131-137).
+- `lib/screens/workout_screen.dart` — `_audio = AudioPlayer()` ~:78, `vibration`
+ import ~:9.
+- `lib/widgets/rep_circle.dart` — `RepCircle` ~:34, per-set completion indicator.
+
+Progress/history surfaces (already rich; animate rather than rebuild):
+- `lib/screens/history_screen_charts.dart` — `_ProgressStatsCard` ~:71,
+ **`_StreakRow` ~:139**, `_WeightChart` ~:192.
+- `lib/screens/history_screen_painter.dart` — `_ChartPainter` ~:9, `paint()` ~:46,
+ `shouldRepaint` ~:121 (static; no animation today).
+- Streak data: `successStreak`/`failStreak` on `ExerciseState`
+ (`lib/services/storage_service.dart` ~:42-46).
+
+Settings (for the sound opt-out):
+- `lib/screens/settings_screen.dart` — `build()` ~:166, `ListView` ~:174-208.
+ Parts: `settings_screen_rows.dart`, `settings_screen_sections.dart`,
+ `settings_screen_exercise_sections.dart`, `settings_screen_thresholds.dart`,
+ `settings_screen_widget.dart`, `settings_screen_actions.dart`.
+ **No `SwitchListTile` exists** — model a new section on `_SyncSection`
+ (`settings_screen_sections.dart` ~:14) or `_OfflineBackupSection`.
+- Store: **SQLite**, not SharedPreferences. There is a ready-made key/value table
+ — schema `lib/services/storage_service_schema.dart` ~:34-36
+ (`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, migration
+ ~:64-65), accessors `lib/services/storage_service.dart` ~:122-137
+ (`_getSetting`/`_setSetting`, both **private** — add a public typed wrapper like
+ the existing `setLastWorkoutType`). Already backed up/restored via
+ `lib/services/storage_service_backup.dart` ~:23, :43, :156-163.
+
+Theme: `lib/ui/theme.dart` — local hand-copy, **no `design_system` dependency**
+(`buildAppTheme()` ~:12, `AppStatusColors` ~:76, `AppSpacing` ~:111,
+`AppRadius` ~:133, `AppTextSize`). Wired at `lib/main.dart` ~:115.
+
+## must
+
+**Step 1 — motion + haptics (no new dependency).**
+
+- Add `HapticFeedback.selectionClick()` on **set completion** — the tap at
+ `exercise_tile_rows.dart` ~:145. This is the app's most repeated action; it
+ should feel like flipping a physical switch. Fire on tap, not after the write.
+- Add a completion cue when a **workout finishes** — haptic plus a visible
+ arrival for `WorkoutSummaryDialog`, rather than it appearing instantly.
+- Replace the inline `Duration(milliseconds: 200)` at ~:146-147 with the motion
+ token from prompt 01, transcribed into `lib/ui/theme.dart`.
+- Animate the streak/stat numbers on the history screen to their new values
+ instead of snapping. `_ChartPainter.shouldRepaint` currently returns a static
+ result — leave the chart itself alone unless animating it is cheap.
+- Honour `MediaQuery.of(context).disableAnimations` — durations collapse to zero;
+ haptics and sound still fire.
+- `VIBRATE` is already granted (`android/app/src/main/AndroidManifest.xml` ~:10)
+ and `vibration: ^3.1.0` is already a dependency. Prefer `HapticFeedback` from
+ `flutter/services.dart` for short UI taps and reserve the `vibration` package
+ for the long timed buzz it already does well.
+
+**Step 2 — sound (separate, independently revertable).**
+
+- Add a finish-workout cue asset alongside `assets/sounds/break_end.mp3`
+ (declared at `pubspec.yaml` ~:52-53 — **this is the fleet's only `assets:`
+ block**, so you are adding a file, not the block).
+- Sound ships **on**, with an opt-out. Add a public
+ `getSoundEnabled()`/`setSoundEnabled(bool)` on `StorageService` wrapping
+ `_getSetting`/`_setSetting` (store `'true'`/`'false'`), then a settings section
+ with a `SwitchListTile`.
+- The toggle must silence the **new** cues and the existing break-end cue alike —
+ one switch, not two behaviours.
+
+**Both steps:**
+
+- must not: **reward the weight or rep number.** The cue fires on *logging a set*
+ and *finishing a workout*, not on hitting a PB or beating last week. Rewarding
+ the number pushes toward misreporting; rewarding the record keeps the data
+ honest. A deloaded set gets the same confirmation as a PB.
+- must not: fabricate the streak. `successStreak`/`failStreak` count real
+ outcomes; display them as they are.
+- must not: add a cue to *deleting* a set or workout.
+- must not: silently swallow an audio error. The existing `.catchError` →
+ `debugPrint` at ~:131-137 is the local precedent; do not make it quieter.
+- must not: break the **100% coverage gate** — new public methods on
+ `StorageService` need tests. See the comments at
+ `settings_screen_sections.dart` ~:1-11.
+- must not: exceed the **250-line cap** on any file.
+
+- optional: a subtle near-miss cue on the history screen when a streak is one
+ workout short of a milestone — legitimate only because the number is true.
+
+## done
+
+1. Completing a set produces an immediate haptic on the phone.
+2. Finishing a workout produces a haptic and a visible dialog arrival; step 2
+ adds a sound.
+3. No inline animation duration remains in `exercise_tile_rows.dart`.
+4. `cd ~/screen-locker/stronglift_replacement/workout_app && flutter analyze` clean.
+5. `flutter test` passes **and coverage stays at 100%**.
+6. With OS "remove animations" on, the app works and durations are zero.
+7. Step 2: the settings toggle flips, persists across restart, and silences both
+ the new cues and the existing break-end cue.
+
+## verify
+
+**On the phone.**
+
+```
+adb devices # confirm 23181JEGR08034
+cd ~/screen-locker/stronglift_replacement/workout_app
+flutter build apk --release
+adb install -r build/app/outputs/flutter-apk/app-release.apk
+```
+
+Never `flutter install`, never `adb uninstall`, never `pm clear` — those wipe the
+SQLite workout history. `adb install -r` preserves it.
+
+Then run a real set on the device: tap a set complete, finish a workout, and
+report what the phone actually did. Haptics and sound are physical outcomes —
+state what you felt and heard; do not infer them from the code.
+
+## read first
+
+- `lib/screens/workout_screen_session.dart` ~:128-145 — `_playBreakEndCue()`, the
+ working cue. Your new cues should look like it.
+- `lib/widgets/exercise_tile_rows.dart` — the set-completion tap and the fleet's
+ only `AnimatedContainer`.
+- `lib/services/storage_service.dart` ~:122-137 — `_getSetting`/`_setSetting`, and
+ `setLastWorkoutType` as the public-wrapper pattern.
+- `lib/screens/settings_screen_sections.dart` ~:1-11 — the coverage/line-cap
+ comments, and `_SyncSection` as the section pattern.
+- `~/utils/unified-design-system/motion.md` — motion/haptic vocabulary from
+ prompt 01. **Prompt 01 must have run first.**
+
+## context you would otherwise rediscover
+
+- This app has **no `design_system` dependency**; `lib/ui/theme.dart` is a
+ hand-transcription of the shared tokens. Motion tokens must be transcribed
+ there, matching the existing convention. Do not add the dependency here.
+- `shared_preferences` **is** a dependency but is used only for the device id
+ (`lib/services/sync_device_id.dart` ~:37-38). Settings go in SQLite.
+- Non-motion `Duration`s that are **not** animation and must not be touched:
+ rest tickers (`workout_screen.dart` ~:91, `workout_screen_breaks.dart` ~:20,
+ `workout_screen_session.dart` ~:37 — 1s), settings debounces
+ (`settings_screen.dart` ~:140, :148 — 600ms), `main.dart` ~:56 (20s).
+- The app syncs to the screen-locker Python side, which treats
+ `screen_locker/workout_log.json` as the single source of truth. **Do not change
+ what counts as a workout** — enforcement depends on it, and it is out of scope.
+- The repo enforces a 250-line file cap and bans `# noqa`; ruff runs
+ `select = ["ALL"]` on the Python side.
diff --git a/dopamine-ux/06-todo.md b/dopamine-ux/06-todo.md
new file mode 100644
index 0000000..d3f5eea
--- /dev/null
+++ b/dopamine-ux/06-todo.md
@@ -0,0 +1,180 @@
+Adopt the motion tokens in todo, and make capture feel immediate
+
+## findings (3, ranked by value over effort)
+
+1. **Capture gives no confirmation** — a successful capture is indistinguishable
+ from a no-op unless you go looking for the note.
+2. **Zero motion, zero haptics** — despite being the only app already wired to
+ the shared package, so adoption here is cheapest.
+3. **The pinned `design_system` tag predates the motion tokens** — nothing
+ compiles until the ref is bumped. Easy to forget, fatal if skipped.
+
+## what
+
+`todo` is the only app in kuhy's fleet already wired to the shared
+`design_system` package — it has **deliberately no local `theme.dart`** (the
+pubspec comment says so: nothing to drift from the frozen table). That makes it
+the cheapest place to adopt the motion tokens added by prompt 01, and the
+reference implementation the other apps get compared against.
+
+Today it has **zero motion and zero haptics**. Verified 2026-08-16: no
+`Animated*`, no `AnimationController`, no `HapticFeedback`, no audio. Its only
+progress UI is the note-editor wizard bar. Capturing a note — the app's whole
+purpose, and the thing that has to feel frictionless enough to actually do —
+gives nothing back beyond a toast on error.
+
+Per kuhy's rule for this project: **reward the capture, never the completion
+count.** A todo app that celebrates finishing things teaches you to log easy
+things; one that celebrates *capturing* keeps the backlog honest.
+
+## where
+
+Repo: `~/todo`.
+
+**First step — bump the dependency.** `pubspec.yaml` pins `design_system` at git
+ref `design_system-v0.1.0`, which predates the motion tokens. Bump it to
+**`design_system-v0.2.0`** (cut by prompt 01) or the tag that prompt actually
+printed. Without this, `AppDuration` does not exist and nothing else here compiles.
+
+Primary:
+- `lib/ui/capture_screen.dart`, `lib/ui/capture_screen_widget.dart`,
+ `lib/ui/capture_screen_sync.dart` — the capture path. `showError` ~:76 and
+ `showToast` ~:78 in `capture_screen_sync.dart` are the fleet's only uses of the
+ shared feedback helpers.
+- `lib/ui/capture_app_bar.dart` — progress indicators ~:66, :84.
+- `lib/ui/note_editor.dart` — `showError` ~:115.
+- `lib/ui/note_editor_step_page.dart` ~:80 — `LinearProgressIndicator(value:
+ (index+1)/total)`, the wizard step bar. The one honest progress surface.
+- `lib/ui/notes_list_screen.dart` — `_searchDebounce = 250ms` ~:45 (**not**
+ animation; do not tokenise it).
+- `lib/ui/notes_list_navigation.dart`, `lib/ui/settings_screen.dart` (2×),
+ `lib/ui/capture_screen.dart` — the `MaterialPageRoute` sites, all using the
+ default platform transition.
+
+Settings — **this app has the only working toggle in the fleet; it is the
+pattern the other prompts copy**:
+- `lib/ui/settings_screen.dart` ~:213-225 — `ValueListenableBuilder`
+ wrapping a `SwitchListTile`; setter ~:111-140 (logs an `AnalyticsEvent`, opens
+ a Firebase client, calls `withAdvancedMode`).
+- `lib/data/app_settings.dart` — `AppSettings` immutable class,
+ `SharedPreferences` keys ~:38-39, `load()` ~:42, `withAdvancedMode` ~:60,
+ `reconcileWithRemote` ~:98, `adopt` ~:132 (last-writer-wins cross-device sync
+ via `settings/advancedMode` ~:20). Held app-wide as a `ValueNotifier`.
+
+Theme: no local file. `lib/main.dart` ~:4 imports `design_system`; ~:60-61 wires
+`theme: buildLightTheme(), darkTheme: buildDarkTheme()`. Source of truth is
+`~/utils/design_system/lib/src/theme.dart`.
+
+## must
+
+**Step 1 — motion + haptics (no new dependency).**
+
+- Bump `design_system` to the new tag **first**, and confirm `AppDuration` /
+ `AppCurve` resolve before writing any other code.
+- Add `HapticFeedback.selectionClick()` (or the token vocabulary's "confirm" cue)
+ on **successful note capture**. `HapticFeedback` comes from
+ `package:flutter/services.dart` — no pubspec change. Add
+ `` to
+ `android/app/src/main/AndroidManifest.xml`; it is absent today.
+- Fire the haptic **on the tap**, not after the Firebase sync returns. The whole
+ point of the micro-animation idea is that immediate feedback removes the
+ perceived latency; a cue gated on a network round-trip reintroduces it.
+- Give the capture a visible confirmation — currently a successful capture is
+ indistinguishable from a no-op unless you go looking.
+- Animate the wizard `LinearProgressIndicator` between steps rather than jumping.
+- Apply the shared page-transition/motion tokens to the `MaterialPageRoute` sites
+ so navigation matches the rest of the system.
+- Honour `MediaQuery.of(context).disableAnimations`: durations collapse to zero,
+ haptics still fire.
+
+**Step 2 — sound (separate, independently revertable).**
+
+- Add an audio dependency and an `assets:` block to `pubspec.yaml` (neither
+ exists) plus a short capture-confirm cue.
+- Sound ships **on**, with an opt-out `SwitchListTile` — copy the
+ `advancedMode` pattern verbatim (widget ~:213-225, setter ~:111-140, field in
+ `app_settings.dart`). It is the fleet's proven toggle; do not invent a new one.
+- Consider whether the sound preference should sync across devices like
+ `advancedMode` does, or stay local. Local is the safer default — a per-device
+ audio choice is usually what people mean.
+
+**Both steps:**
+
+- must not: reward **completing** a todo more than capturing one. No streak on
+ completions, no counter that makes finishing feel better than recording. The
+ backlog is only useful if capture stays frictionless and honest.
+- must not: add a variable/random reward to capture. There is no real pool to
+ draw from here, so any variance would be manufactured — which fails the
+ project's second test.
+- must not: fabricate progress. The wizard bar's `(index+1)/total` is true; keep
+ it that way.
+- must not: hand-copy token values into a local `theme.dart`. This app
+ deliberately has none — that is a feature, and the pubspec comment says so.
+- must not: add `# noqa`-equivalents or suppress analyzer warnings.
+
+- optional: animate the notes-list reordering/filtering, if it falls out cheaply
+ from the shared tokens.
+
+## done
+
+1. `pubspec.yaml` references the new `design_system` tag and `flutter pub get`
+ resolves it.
+2. Capturing a note produces a haptic within ~50ms of the tap and a visible
+ confirmation.
+3. Wizard step transitions and page navigation use shared motion tokens; no
+ inline `Duration` remains for animation purposes (the 250ms search debounce
+ and 5s auto-sync debounce stay as they are — they are not motion).
+4. `cd ~/todo && flutter analyze` is clean.
+5. `cd ~/todo && flutter test` passes.
+6. With OS "remove animations" enabled, the app works and durations are zero.
+7. Step 2: the sound toggle flips, persists across restart, and silences the cue.
+
+## verify
+
+**On the phone — todo is mobile-primary.**
+
+```
+adb devices # confirm 23181JEGR08034
+cd ~/todo
+flutter build apk --release
+adb install -r build/app/outputs/flutter-apk/app-release.apk
+```
+
+Never `flutter install`, never `adb uninstall`, never `pm clear` — those wipe
+real notes. `adb install -r` preserves them.
+
+Then capture a real note on the device and report what the phone did. A haptic is
+a physical outcome: say what you felt, do not infer it from code. Also step
+through the note-editor wizard to confirm the progress bar animates.
+
+If the app also has a desktop/web surface deployed under `/opt/`, note that it
+can go stale independently — but this prompt's done-condition is the phone.
+
+## read first
+
+- `lib/ui/settings_screen.dart` ~:213-225 and `lib/data/app_settings.dart` — the
+ toggle pattern, in full, before writing the sound switch.
+- `lib/ui/capture_screen_sync.dart` ~:76-78 — how `showToast`/`showError` are
+ used today; the shared helpers live in
+ `~/utils/design_system/lib/src/feedback.dart`.
+- `~/utils/design_system/lib/src/feedback.dart` — `showToast`/`showError`, `_show`
+ ~:50. `confirm.dart` beside it **already imports `flutter/services.dart`**, so
+ if prompt 01 added a shared haptic helper it lives near here.
+- `~/utils/unified-design-system/motion.md` — the vocabulary from prompt 01.
+ **Prompt 01 must have run first, and its tag must be cut.**
+- `pubspec.yaml` — read the comment explaining why there is no local theme.
+
+## context you would otherwise rediscover
+
+- `showToast` and `showError` already carry fixed dwell times in the shared
+ package (`_toastDuration = 3s`, `_errorDuration = 5s`,
+ `feedback.dart` ~:14, :18). Those are **not** motion tokens; leave them.
+- This is the **only** app on `design_system`. diet-guard, workout_app and
+ wake_alarm hand-transcribe tokens into local theme files, so a widget copied
+ from this app into one of those will not compile unmodified.
+- The app has no streaks, no rings, no history charts, and no completion counts
+ today. That is a deliberate starting point for this prompt, not an oversight to
+ fix wholesale.
+- Settings sync cross-device last-writer-wins through Firebase
+ (`settings/advancedMode`). If you add a synced preference, follow
+ `reconcileWithRemote`/`adopt`; if local-only, skip that path entirely.
diff --git a/dopamine-ux/07-dufs-theme.md b/dopamine-ux/07-dufs-theme.md
new file mode 100644
index 0000000..efbc608
--- /dev/null
+++ b/dopamine-ux/07-dufs-theme.md
@@ -0,0 +1,139 @@
+Wire dufs-cloud's Flutter app to the shared design_system
+
+## findings (2, ranked by value over effort)
+
+1. **`app/lib/ui/theme.dart` is a fifth, uncompared palette copy** — hand-copied
+ hexes that `palette_check.py` has never seen, so it can drift silently while
+ CI stays green.
+2. **The web half of the same repo is wired correctly** — which makes the
+ Flutter half's divergence an accident, not a decision.
+
+## what
+
+`~/dufs-cloud/app/lib/ui/theme.dart` is a **fifth, uncompared copy of the
+palette**. Verified 2026-08-16: the Flutter app has no `design_system`
+dependency at all — it hand-restates every hex (`Color(0xFF211D1B) // ink`,
+`Color(0xFFB8862E) // accent`, …) with the shared token names appearing only in
+comments. `palette_check.py` has never seen this file, so it can drift from the
+frozen table silently and CI will stay green.
+
+The web half of the same repo is wired correctly (`@kuhyx/web-ui`), which makes
+the Flutter half's divergence an accident rather than a decision.
+
+Fix the root cause: add the dependency, replace the hand-copied hexes with token
+references, and delete the duplicate. This must land **before** prompt 08 adds
+motion to this app, so that motion tokens arrive through the package instead of
+becoming a sixth hand-copy.
+
+## where
+
+Repo: `~/dufs-cloud`. Flutter app: `~/dufs-cloud/app` (package `dufs_client`).
+
+- `app/pubspec.yaml` — current deps: `archive, cupertino_icons, flutter,
+ flutter_secure_storage, http, image_picker, media_kit*, path, path_provider,
+ share_plus, shared_preferences, webview_flutter, xml`. **No `design_system`.**
+- `app/lib/ui/theme.dart` — the hand-copy to eliminate.
+- Every file referencing a colour from it — find them with a grep for the theme's
+ exported symbols rather than trusting any list here.
+
+Source of truth:
+- `~/utils/design_system/lib/src/tokens.dart` — `AppPalette`, `AppSpacing`,
+ `AppRadius`, `AppTextSize` (plus `AppDuration`/`AppCurve` if prompt 01 ran).
+- `~/utils/design_system/lib/src/theme.dart` — `buildLightTheme()`,
+ `buildDarkTheme()`.
+
+Reference implementation: `~/todo` is the only app already on the package. Its
+`pubspec.yaml` uses a **git ref** (`design_system-v0.1.0`) and it has
+deliberately **no local `theme.dart`**. Copy that shape.
+
+## must
+
+- Add `design_system` to `app/pubspec.yaml` using the **git ref** style matching
+ `~/todo` — not a local `path:` dependency. One dependency convention across the
+ fleet. Pin the tag prompt 01 cut (`design_system-v0.2.0` or whatever it
+ printed); if prompt 01 has not run, pin `design_system-v0.1.0` and let prompt
+ 08 do the bump.
+- Replace every hand-copied hex in `app/lib/ui/theme.dart` with the corresponding
+ token reference. **Verify each swap is value-identical before making it** — the
+ survey found the values byte-identical today, but confirm rather than assume;
+ a silent colour change in a live app is a real regression.
+- Delete the duplicated token declarations once nothing references them. If the
+ app declares helper classes the shared package lacks, keep only those, and say
+ in the session summary which ones survived and why.
+- Keep the app's visual output **identical**. This is a pure de-duplication: if
+ anything looks different afterwards, a value was wrong and you have found a
+ pre-existing drift — report it rather than papering over it.
+
+- must not: change any colour, spacing, radius or type **value**. The shared
+ table is a freeze. If a value genuinely disagrees, stop and surface it — that
+ is a finding, and kuhy decides which side is canonical.
+- must not: add motion/animation here. That is prompt 08. This prompt is
+ dependency plumbing only, so that a visual regression has exactly one possible
+ cause.
+- must not: touch `~/dufs-cloud/web/` — its `@kuhyx/web-ui` wiring is already
+ correct.
+- must not: leave a partial migration. Either the local theme file is gone (or
+ reduced to app-specific extras) and everything resolves through the package, or
+ the change is not done. A half-migrated theme is worse than the current state
+ because it hides *which* copy is authoritative.
+
+- optional: once migrated, add `app/lib/ui/theme.dart`'s path to the structural
+ checker from prompt 02 if that is now meaningful. Probably it is not — after
+ this prompt there is nothing left there to compare.
+
+## done
+
+1. `app/pubspec.yaml` declares `design_system` at a git ref; `flutter pub get`
+ resolves.
+2. No hard-coded palette hex remains in `app/lib/`. A grep for `Color(0xFF` in
+ `app/lib/ui/theme.dart` returns nothing (or only app-specific values that are
+ not palette tokens — name them if so).
+3. `cd ~/dufs-cloud/app && flutter analyze` is clean.
+4. `cd ~/dufs-cloud/app && flutter test` passes.
+5. The app looks **the same** as before on device — verified by screenshot
+ comparison, not by reasoning about the diff.
+6. `python3 ~/utils/unified-design-system/scripts/palette_check.py` still exits 0.
+
+## verify
+
+**On the phone**, and with a before/after comparison — a pure refactor is only
+verified by showing nothing changed.
+
+```
+adb devices
+cd ~/dufs-cloud/app
+flutter build apk --release
+adb install -r build/app/outputs/flutter-apk/app-release.apk
+```
+
+Take a screenshot of the browser screen and the settings screen **before**
+starting (from the currently installed build) and after, and compare them. Never
+`adb uninstall` or `pm clear` — the app holds a stored credential
+(`flutter_secure_storage`) and wiping it means re-authenticating.
+
+## read first
+
+- `~/dufs-cloud/app/lib/ui/theme.dart` — in full, before touching anything. The
+ comments naming each token are your mapping table.
+- `~/todo/pubspec.yaml` — the git-ref dependency line to copy, and the comment
+ explaining why no local theme exists.
+- `~/utils/design_system/lib/src/tokens.dart` — the canonical names.
+- `~/dufs-cloud/DESIGN_AUDIT_TODO.md` — a prior audit of this repo; check whether
+ it already records theme findings so you do not contradict it.
+
+## context you would otherwise rediscover
+
+- The web half consumes `@kuhyx/web-ui` at tag **`web_ui-v0.3.1`**, declared in
+ `~/dufs-cloud/web/package.json` as
+ `"github:kuhyx/utils#web_ui-v0.3.1&path:/web_ui"`. It is correctly wired; the
+ only app-local CSS value is `--overlay: rgba(33, 29, 27, 0.82)` in
+ `web/src/index.css`. Prompt 08 bumps that tag — not this prompt.
+- `@kuhyx/web-ui` is **pnpm-only**; npm cannot install a monorepo subdirectory.
+ Irrelevant here (this prompt is Flutter-only) but fatal if you wander into
+ `web/`.
+- `palette_check.py` compares four sources and has never included this file, so
+ "CI is green" told you nothing about it. That is the whole reason this prompt
+ exists.
+- Three other Flutter apps (diet-guard, workout_app, wake_alarm) have the same
+ hand-copy pattern. They are **out of scope** — migrating them is a separate,
+ larger job, and doing one app properly is the point here.
diff --git a/dopamine-ux/08-dufs-motion.md b/dopamine-ux/08-dufs-motion.md
new file mode 100644
index 0000000..f8809f3
--- /dev/null
+++ b/dopamine-ux/08-dufs-motion.md
@@ -0,0 +1,196 @@
+Give dufs-cloud real latency feedback on its slow operations
+
+## findings (4, ranked by value over effort)
+
+1. **No cancel on the unbounded index walk or the ZIP build** — the real
+ usability failure, and worth more than any animation.
+2. **Slow operations report one static string** — the walk and the zip both know
+ real counts and discard them.
+3. **No `prefers-reduced-motion` block anywhere in 806 lines of CSS** — a
+ straight accessibility gap.
+4. **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
+ indeterminate `CircularProgressIndicator`. **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. Neither `buildSelectionZip` signature even
+ accepts a progress callback.
+- Web's entire 806-line `index.css` contains **exactly one transition**
+ (`.tile-actions { transition: opacity 0.12s }` ~:539-544), zero `@keyframes`,
+ and **no `prefers-reduced-motion` block at all**.
+- Flutter side has **zero animations and zero haptics** — notably
+ `browser_screen.dart` (~:95) enters multi-select on **long-press** with no
+ `HapticFeedback.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: `~/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 to **`web_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`: bump `design_system` to 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, `loading` derived ~:113); Flutter
+ `app/lib/services/cloud_index.dart` (`buildCloudIndex` ~:25, `_walk` ~:31-41),
+ driven by `app/lib/screens/browser_screen.dart` `_ensureIndex()` ~:184-194.
+- ZIP build — web `web/src/lib/download.ts` (`gatherFiles` ~:24,
+ `buildSelectionZip` ~:37-47, `saveBytes` ~:55; encoder `web/src/lib/zip.ts`
+ `zipStore` ~:32), triggered from `web/src/components/gallery.tsx` ~:307;
+ Flutter `app/lib/services/download_zip.dart` (`gatherFiles` ~:15,
+ `buildSelectionZip` ~:29-37).
+- Upload — web `gallery.tsx` ~:186; Flutter
+ `app/lib/services/dufs_client.dart` `upload()` ~:73-81 (a plain `_http.put`
+ with the full body — **not** a `StreamedRequest`, so byte progress is not
+ currently observable), called from `browser_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; Flutter
+ `browser_screen.dart` ~:120/:140/:149/:155.
+
+Other surfaces:
+- `web/src/index.css` (806 lines; `.busy` styled ~:87 — static text, no shimmer).
+- `web/src/components/filter-bar.tsx` ~:75-83 — the search input fires `onFilter`
+ on **every keystroke with no debounce**.
+- `web/src/components/gallery.tsx` ~:454 `{loading && Loading…
}`,
+ ~:355 the `busy` span; `folder-picker.tsx` ~:97.
+- Flutter spinners: `browser_screen.dart` ~:717, ~:732, `RefreshIndicator` ~:748
+ (the one built-in motion affordance), plus `text_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),
+ store `app/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`). Both
+ `buildSelectionZip` signatures 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 `` to the manifest if absent.
+- Add a `@media (prefers-reduced-motion: reduce)` block to `web/src/index.css`.
+ **There is none today** — this is a straight accessibility gap, not a nicety.
+ Flutter side: honour `MediaQuery.of(context).disableAnimations`.
+- Use the shared motion tokens for any transition you add. Replace the inline
+ `0.12s` at `index.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.dart` backed by `app/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.dart` mirror 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
+
+1. Starting a cloud index shows a live, **true** count and can be cancelled — on
+ both web and Flutter.
+2. A multi-file ZIP shows per-file progress and can be cancelled.
+3. `web/src/index.css` has a `prefers-reduced-motion` block; with the OS setting
+ on, transitions are suppressed.
+4. Long-press multi-select produces a haptic on the phone.
+5. `cd ~/dufs-cloud/web && pnpm install && pnpm build && pnpm test` passes
+ (use the repo's actual script names).
+6. `cd ~/dufs-cloud/app && flutter analyze && flutter test` clean.
+7. 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 ~/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.ts` and `app/lib/services/cloud_index.dart` —
+ read both before changing either; they mirror each other deliberately.
+- `web/src/lib/download.ts` and `app/lib/services/download_zip.dart` — same.
+- `web/src/components/gallery.tsx` — the `setBusy` call sites; they are the
+ inventory of every operation currently reduced to one static string.
+- `~/dufs-cloud/DESIGN_AUDIT_TODO.md` — prior findings for this repo.
+- `~/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 no
+ `design_system` dependency and hand-restates the palette in
+ `app/lib/ui/theme.dart`.
+- Web tokens come from `@kuhyx/web-ui` imported in `web/src/main.tsx`;
+ `web/src/index.css` declares 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 a `StreamedRequest` refactor. 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.
diff --git a/dopamine-ux/09-wake-alarm.md b/dopamine-ux/09-wake-alarm.md
new file mode 100644
index 0000000..86d4b2a
--- /dev/null
+++ b/dopamine-ux/09-wake-alarm.md
@@ -0,0 +1,162 @@
+Add motion and haptics to the wake-alarm phone app
+
+## findings (2, ranked by value over effort)
+
+1. **Setting an alarm gives no confirmation or haptic** — and since the real
+ alarm lives in another app entirely, confirming the handoff is the only
+ reassurance this app can offer.
+2. **Zero motion, and the thinnest local theme in the fleet** — it lacks
+ `AppSpacing`/`AppRadius`/`AppTextSize`, so widgets copied from sibling apps
+ will not compile here.
+
+## what
+
+`~/wake-alarm/phone_app` is the smallest surface in this project: 12 Dart files,
+a sync companion that sets a time, syncs it, and hands off to the system clock
+app. It has **zero motion, zero haptics, zero audio, zero progress feedback** —
+verified 2026-08-16. Its settings screen is a 56-line `StatelessWidget` with two
+`ListTile`s and no state at all.
+
+Bring it up to the fleet's baseline: a confirmation the alarm was actually set,
+motion tokens on its transitions, and a haptic on the one action that matters.
+
+**This app is deliberately last and deliberately small.** It also carries the
+project's sharpest hazard — see `must not` about alarm audio.
+
+## where
+
+Repo: `~/wake-alarm`. Flutter app: `~/wake-alarm/phone_app` (**underscore**, not
+`phone-app`). The repo root is a Python package; the Flutter app is only the
+`phone_app/` subdirectory.
+
+Primary:
+- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` ~:86-96 builds an
+ `AndroidIntent(action: 'android.intent.action.SET_ALARM', arguments: {HOUR,
+ MINUTES, SKIP_UI: false, MESSAGE: 'Wake Alarm'})` via `android_intent_plus`.
+ The `CircularProgressIndicator` is at ~:189. The only `Duration` in the whole
+ app is `.timeout(const Duration(seconds: 3))` ~:95 — **an intent timeout, not
+ animation. Do not tokenise it.**
+- `phone_app/lib/screens/settings_screen.dart` — 56 lines, `StatelessWidget`,
+ `ListView` ~:21-53, `MaterialPageRoute` ~:30, :49.
+- `phone_app/lib/services/sync_settings.dart` — holds **only** the GitHub token
+ in the keystore (`FlutterSecureStorage`, `_secureTokenKey = 'github_token'`
+ ~:31, `load()` ~:36, `save()` ~:61). There is no general app-preferences class.
+- `phone_app/lib/ui/theme.dart` — local hand-copy, **no `design_system`
+ dependency**. `buildAppTheme()` ~:12, `AppStatusColors` ~:54. ⚠️ Unlike the
+ other apps it does **not** declare `AppSpacing`/`AppRadius`/`AppTextSize`, so a
+ widget copied from diet-guard or workout_app **will not compile here**. Wired at
+ `phone_app/lib/main.dart` ~:18.
+
+## must
+
+**Step 1 — motion + haptics (no new dependency).**
+
+- Add `HapticFeedback.mediumImpact()` when the alarm is successfully set — the
+ single most important action in the app. Fire it on the tap, not after the
+ intent round-trips. `HapticFeedback` is in `package:flutter/services.dart`; add
+ `` to
+ `phone_app/android/app/src/main/AndroidManifest.xml` if absent.
+- Give setting the alarm a **clear confirmation**: which time was set, and that
+ the handoff to the system clock succeeded. Today the user gets a `_status`
+ string and a spinner. Since the actual alarm lives in another app, confirming
+ the handoff is the only reassurance this app can offer — make it unambiguous.
+- Transcribe the motion tokens from prompt 01 into `phone_app/lib/ui/theme.dart`,
+ matching the existing hand-copy convention. Add `AppSpacing`/`AppRadius`/
+ `AppTextSize` **only if** you need them; otherwise leave the file's shape alone.
+- Apply motion tokens to the two `MaterialPageRoute` transitions.
+- Honour `MediaQuery.of(context).disableAnimations`.
+
+**Step 2 — sound (optional here; consider skipping).**
+
+- A UI confirmation cue would need an audio dependency, an `assets:` block, a
+ settings toggle, **and** a preferences class that does not exist yet (see
+ below). For an app whose entire job is to hand off to another app, that is a
+ poor value ratio.
+- **Recommendation: skip sound in this app** and say so explicitly in the session
+ summary. If you do add it, it needs the full opt-out toggle like everywhere
+ else — and the toggle work below.
+- To add any toggle here you must do three things the other apps do not require:
+ (a) convert `SettingsScreen` from `StatelessWidget` to `StatefulWidget`,
+ (b) create a preferences class — there is no `AppSettings` equivalent; model it
+ on `~/todo/lib/data/app_settings.dart`, and note `shared_preferences: ^2.3.0`
+ is **already a dependency**, (c) add the `SwitchListTile` to the `ListView`.
+
+**Both steps:**
+
+- must not: **touch anything under `~/wake-alarm/wake_alarm/`.** That is the
+ desktop Python daemon that produces the real alarm noise — sine-tone WAVs via
+ `paplay` → `aplay` → `speaker-test` (`_audio.py`, `_play_on_all_sinks` ~:218,
+ pcspkr evdev fallback `_beep_pcspkr` ~:81), escalating 440Hz → 1000Hz → loud in
+ the loop at `_alarm.py` ~:393-407, with sink volume save/restore in `_sinks.py`.
+ **Never route a UI-sound opt-out through any of it.** A toggle that silences a
+ wake-up alarm is a bug that makes someone late for work.
+- The two systems never share a process, package, or file — `phone_app` has no
+ audio engine at all — so a UI sound layer here **structurally cannot** touch
+ alarm playback. Keep it that way; do not build a bridge between them.
+- must not: change the alarm-setting intent, its arguments, or the 3s timeout.
+ Feedback wraps that call; it does not modify it.
+- must not: add a streak, a counter, or any reward for setting an alarm. Waking
+ up is not a metric this app owns, and rewarding "alarms set" rewards the wrong
+ act entirely.
+- must not: copy a widget from another app without checking it only uses symbols
+ this app's thinner theme actually declares.
+
+## done
+
+1. Setting an alarm produces a haptic and an unambiguous confirmation naming the
+ time that was set.
+2. Page transitions use the shared motion tokens; no inline animation `Duration`
+ remains (the 3s intent timeout stays).
+3. `cd ~/wake-alarm/phone_app && flutter analyze` is clean.
+4. `cd ~/wake-alarm/phone_app && flutter test` passes.
+5. With OS "remove animations" enabled, the app works and durations are zero.
+6. `git diff` shows **no changes under `~/wake-alarm/wake_alarm/`**.
+7. The summary states whether sound was added or deliberately skipped.
+
+## verify
+
+**On the phone.**
+
+```
+adb devices # confirm 23181JEGR08034
+cd ~/wake-alarm/phone_app
+flutter build apk --release
+adb install -r build/app/outputs/flutter-apk/app-release.apk
+```
+
+Never `flutter install`, never `adb uninstall`, never `pm clear` — the app stores
+a GitHub token in the keystore and wiping it means re-authenticating.
+
+Then set a real alarm on the device: confirm the haptic fired (state it as a
+physical observation), the confirmation named the right time, and **the system
+Clock app actually received the alarm**. The last one is the real test — this
+app's job is the handoff, so verify the alarm exists in the Clock app, not just
+that this screen said so.
+
+Do **not** test by letting an alarm actually fire unless kuhy asks — that
+involves the desktop daemon and real noise.
+
+## read first
+
+- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` in full,
+ including the timeout and the `_status` string handling.
+- `phone_app/lib/ui/theme.dart` — note what it does **not** declare before
+ writing any widget.
+- `~/todo/lib/data/app_settings.dart` — the preferences-class pattern, only if
+ you decide to do step 2.
+- `~/utils/unified-design-system/motion.md` — vocabulary from prompt 01.
+ **Prompt 01 must have run first.**
+
+## context you would otherwise rediscover
+
+- **The Flutter app produces no audio whatsoever.** No audio package, no
+ `assets:` block; `assets/` contains only launcher icons. It fires an Android
+ `SET_ALARM` intent and the **system Clock app** rings. The desktop Python
+ daemon is an entirely separate program on a different machine.
+- The repo root (`~/wake-alarm/wake_alarm/`, `wake_alarm.egg-info`) is the Python
+ package. Only `phone_app/` is Flutter.
+- This app has **no `design_system` dependency** and the thinnest local theme in
+ the fleet. Adding the dependency is out of scope here (see prompt 07 for what
+ that migration looks like when done properly).
+- No streaks, no charts, no progress, no completion counts exist — and none
+ should be added. This is a two-button utility.
diff --git a/dopamine-ux/verify_prompts.py b/dopamine-ux/verify_prompts.py
new file mode 100644
index 0000000..c1c8b62
--- /dev/null
+++ b/dopamine-ux/verify_prompts.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+"""Verify every path and symbol cited in the dopamine-ux prompt files.
+
+A copy-pasteable prompt that points at a file which does not exist is worse than
+no prompt, so this gate adjudicates with an exit code rather than by eye.
+
+Two checks:
+ 1. Every cited on-disk path resolves.
+ 2. Every cited symbol is greppable in the file it is attributed to. A path
+ check alone cannot catch a stale line number; a symbol grep can.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+PROMPT_DIR = Path.home() / "utils" / "dopamine-ux"
+HOME = Path.home()
+
+PATH_RE = re.compile(r"`(~?/[^`\s]+|[\w.\-]+/[\w./\-]+\.\w+)`")
+
+# Not on-disk paths despite matching the shape above.
+SKIP_SUBSTRINGS = (
+ "://",
+ "$",
+ "*",
+ "<",
+ ">",
+ "…",
+ "devices/", # Firebase/crdt key path
+ "settings/advanced", # Firebase key path
+ "android.permission",
+ "android.intent",
+ "/sdcard/",
+ "/etc/",
+ "/dev/input/",
+ "/opt/",
+ "com.kuhy",
+ "app-release.apk",
+ "build/app/outputs", # build outputs
+ "flutter/services.dart",
+ "flutter/material.dart", # package imports
+)
+
+# Files a prompt CREATES. Citing them forward is correct, so absence is expected
+# -- but pairing each with its creating prompt still catches a typo.
+CREATED_BY_PROMPT = {
+ "~/utils/unified-design-system/motion.md": "01",
+ "~/utils/unified-design-system/scripts/structural_check.py": "02",
+}
+
+# Cited precisely to state that they do NOT exist.
+ASSERTED_ABSENT = {"~/gatelock"}
+
+# Generic prose fragments rather than citations of one specific file.
+PROSE_FRAGMENTS = {"lib/ui/theme.dart", "lib/main.dart", "pubspec.yaml"}
+
+# Symbols each prompt claims exist, as (repo-relative file, symbol).
+SYMBOL_CLAIMS = [
+ ("~/diet-guard/app/lib/screens/log_meal_screen.dart", "_onLogMeal"),
+ ("~/diet-guard/app/lib/widgets/today_progress_card.dart", "TodayProgressCard"),
+ ("~/diet-guard/app/lib/widgets/streak_summary_row.dart", "StreakSummaryRow"),
+ ("~/diet-guard/app/lib/screens/log_meal_progress.dart", "buildTodayProgress"),
+ ("~/diet-guard/app/lib/services/app_settings_service.dart", "_writeToDisk"),
+ ("~/screen-locker/screen_locker/_status_types.py", "StatusSnapshot"),
+ ("~/screen-locker/screen_locker/_extra_benefits.py", "process_week_transition"),
+ ("~/screen-locker/screen_locker/_extra_benefits.py", "current_streak"),
+ ("~/screen-locker/screen_locker/_unlock_view.py", "unlock_screen"),
+ ("~/screen-locker/screen_locker/_log_mixin.py", "write_signed_entry"),
+ ("~/screen-locker/screen_locker/tests/conftest.py", "_ISOLATED_STATE"),
+ ("~/screen-locker/screen_locker/_weekly_check.py", "WEEKLY_WORKOUT_MINIMUM"),
+ (
+ "~/screen-locker/stronglift_replacement/workout_app/lib/screens/"
+ "workout_screen_session.dart",
+ "_playBreakEndCue",
+ ),
+ (
+ "~/screen-locker/stronglift_replacement/workout_app/lib/screens/"
+ "workout_screen_finish.dart",
+ "_persistFinishedWorkout",
+ ),
+ (
+ "~/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
+ "workout_summary_dialog.dart",
+ "WorkoutSummaryDialog",
+ ),
+ (
+ "~/screen-locker/stronglift_replacement/workout_app/lib/services/"
+ "storage_service.dart",
+ "_getSetting",
+ ),
+ (
+ "~/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
+ "exercise_tile_rows.dart",
+ "AnimatedContainer",
+ ),
+ ("~/todo/lib/data/app_settings.dart", "withAdvancedMode"),
+ ("~/todo/lib/ui/settings_screen.dart", "SwitchListTile"),
+ ("~/dufs-cloud/web/src/hooks/use-cloud-index.ts", "walkInto"),
+ ("~/dufs-cloud/app/lib/services/cloud_index.dart", "buildCloudIndex"),
+ ("~/dufs-cloud/web/src/lib/download.ts", "buildSelectionZip"),
+ ("~/dufs-cloud/app/lib/services/download_zip.dart", "buildSelectionZip"),
+ ("~/wake-alarm/phone_app/lib/screens/home_screen.dart", "_setPhoneAlarm"),
+ ("~/wake-alarm/wake_alarm/_audio.py", "_play_on_all_sinks"),
+ ("~/utils/unified-design-system/scripts/palette_check.py", "NON_COLOUR_CSS"),
+ ("~/utils/unified-design-system/scripts/palette_map.py", "PALETTE"),
+ ("~/utils/design_system/lib/src/feedback.dart", "showToast"),
+ ("~/utils/design_system/lib/src/tokens.dart", "AppSpacing"),
+ ("~/utils/gatelock/gatelock/_window.py", "LockConfig"),
+]
+
+
+def expand(raw: str) -> Path:
+ return HOME / raw[2:] if raw.startswith("~/") else Path(raw)
+
+
+def candidate_paths(text: str) -> set[str]:
+ return {
+ raw
+ for raw in PATH_RE.findall(text)
+ if not any(s in raw for s in SKIP_SUBSTRINGS)
+ and raw not in PROSE_FRAGMENTS
+ and not re.search(r"\.\w+:\d+$", raw) # trailing :line suffix
+ }
+
+
+def prompt_roots(text: str) -> list[Path]:
+ roots = []
+ for label in ("Repo", "App", "Flutter app"):
+ for m in re.finditer(rf"{label}: `(~/[^`]+)`", text):
+ roots.append(expand(m.group(1)))
+ return roots
+
+
+def check_paths() -> list[str]:
+ failures = []
+ checked = 0
+ for prompt in sorted(PROMPT_DIR.glob("*.md")):
+ text = prompt.read_text()
+ roots = prompt_roots(text)
+ for raw in sorted(candidate_paths(text)):
+ if raw in ASSERTED_ABSENT:
+ if expand(raw).exists():
+ failures.append(
+ f"{prompt.name}: `{raw}` is asserted ABSENT but EXISTS"
+ )
+ continue
+ if raw in CREATED_BY_PROMPT:
+ owner = CREATED_BY_PROMPT[raw]
+ cites_owner = re.search(rf"[Pp]rompt {owner}", text) is not None
+ if not prompt.name.startswith(owner) and not cites_owner:
+ failures.append(
+ f"{prompt.name}: cites `{raw}` without noting prompt "
+ f"{owner} creates it"
+ )
+ continue
+
+ checked += 1
+ if raw.startswith(("~/", "/")):
+ if not expand(raw).exists():
+ failures.append(f"{prompt.name}: `{raw}` MISSING")
+ elif not any((r / raw).exists() for r in roots):
+ failures.append(
+ f"{prompt.name}: `{raw}` not found under any of "
+ f"{[str(r) for r in roots]}"
+ )
+ print(f"paths: checked {checked}")
+ return failures
+
+
+def check_symbols() -> list[str]:
+ failures = []
+ for raw_file, symbol in SYMBOL_CLAIMS:
+ path = expand(raw_file)
+ if not path.exists():
+ failures.append(f"symbol source MISSING: {raw_file}")
+ continue
+ if symbol not in path.read_text(errors="replace"):
+ failures.append(f"symbol `{symbol}` NOT FOUND in {raw_file}")
+ print(f"symbols: checked {len(SYMBOL_CLAIMS)}")
+ return failures
+
+
+def main() -> int:
+ n_prompts = len(list(PROMPT_DIR.glob("*.md")))
+ print(f"verifying {n_prompts} prompt files in {PROMPT_DIR}")
+ failures = check_paths() + check_symbols()
+ if failures:
+ print(f"\nFAIL -- {len(failures)} problem(s):")
+ for f in failures:
+ print(f" {f}")
+ return 1
+ print("\nPASS -- every cited path exists and every cited symbol is present")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())