From 35415de2253f400f5e2fb4ae7616b6ff348dfcdc Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 16 Aug 2026 21:09:33 +0200 Subject: [PATCH] Add dopamine-ux session prompts for the UX feedback work Nine copy-pasteable per-repo prompts derived from "The Dopamine Architecture" (london-post.co.uk), plus an index and a verification gate. The four mechanics (variable rewards, micro-animations, sound, near-miss) are aimed at kuhy's own stated goals and gated by two tests: is the displayed state true, and is the variance real. Corollary that shapes every prompt: reward the recording, never the number recorded -- rewarding a calorie target teaches under-eating rather than honest logging. Surveys found the fleet has exactly one animation across four Flutter apps, no motion tokens in any stack, and a populated screen-locker streak that no renderer reads. Two constraints are recorded as must-not lines because a naive implementation would violate them: screen-locker rewards must never reduce enforcement (a banked skip credit was deliberately removed), and wake-alarm's UI sound layer must never touch the desktop alarm daemon. verify_prompts.py checks all 146 cited paths and 30 cited symbols, so a prompt pointing at a renamed function fails loudly instead of wasting a session. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FdApz7EZ1zDfvtvvi1idDv --- dopamine-ux/00-INDEX.md | 126 ++++++++++++++++++ dopamine-ux/01-motion-tokens.md | 138 ++++++++++++++++++++ dopamine-ux/02-structural-check.md | 137 ++++++++++++++++++++ dopamine-ux/03-diet-guard.md | 168 ++++++++++++++++++++++++ dopamine-ux/04-screen-locker.md | 168 ++++++++++++++++++++++++ dopamine-ux/05-workout-app.md | 192 +++++++++++++++++++++++++++ dopamine-ux/06-todo.md | 180 ++++++++++++++++++++++++++ dopamine-ux/07-dufs-theme.md | 139 ++++++++++++++++++++ dopamine-ux/08-dufs-motion.md | 196 ++++++++++++++++++++++++++++ dopamine-ux/09-wake-alarm.md | 162 +++++++++++++++++++++++ dopamine-ux/verify_prompts.py | 200 +++++++++++++++++++++++++++++ 11 files changed, 1806 insertions(+) create mode 100644 dopamine-ux/00-INDEX.md create mode 100644 dopamine-ux/01-motion-tokens.md create mode 100644 dopamine-ux/02-structural-check.md create mode 100644 dopamine-ux/03-diet-guard.md create mode 100644 dopamine-ux/04-screen-locker.md create mode 100644 dopamine-ux/05-workout-app.md create mode 100644 dopamine-ux/06-todo.md create mode 100644 dopamine-ux/07-dufs-theme.md create mode 100644 dopamine-ux/08-dufs-motion.md create mode 100644 dopamine-ux/09-wake-alarm.md create mode 100644 dopamine-ux/verify_prompts.py 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())