mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 03:30:58 +01:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FdApz7EZ1zDfvtvvi1idDv
This commit is contained in:
parent
f8dded33c7
commit
35415de225
126
dopamine-ux/00-INDEX.md
Normal file
126
dopamine-ux/00-INDEX.md
Normal file
@ -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.**
|
||||
138
dopamine-ux/01-motion-tokens.md
Normal file
138
dopamine-ux/01-motion-tokens.md
Normal file
@ -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.
|
||||
137
dopamine-ux/02-structural-check.md
Normal file
137
dopamine-ux/02-structural-check.md
Normal file
@ -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.
|
||||
168
dopamine-ux/03-diet-guard.md
Normal file
168
dopamine-ux/03-diet-guard.md
Normal file
@ -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
|
||||
`<uses-permission android:name="android.permission.VIBRATE"/>` 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`.
|
||||
168
dopamine-ux/04-screen-locker.md
Normal file
168
dopamine-ux/04-screen-locker.md
Normal file
@ -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 <changed 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.
|
||||
192
dopamine-ux/05-workout-app.md
Normal file
192
dopamine-ux/05-workout-app.md
Normal file
@ -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.
|
||||
180
dopamine-ux/06-todo.md
Normal file
180
dopamine-ux/06-todo.md
Normal file
@ -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<AppSettings>`
|
||||
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
|
||||
`<uses-permission android:name="android.permission.VIBRATE"/>` 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.
|
||||
139
dopamine-ux/07-dufs-theme.md
Normal file
139
dopamine-ux/07-dufs-theme.md
Normal file
@ -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.
|
||||
196
dopamine-ux/08-dufs-motion.md
Normal file
196
dopamine-ux/08-dufs-motion.md
Normal file
@ -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 && <p className="muted">Loading…</p>}`,
|
||||
~: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 `<uses-permission
|
||||
android:name="android.permission.VIBRATE"/>` 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.
|
||||
162
dopamine-ux/09-wake-alarm.md
Normal file
162
dopamine-ux/09-wake-alarm.md
Normal file
@ -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
|
||||
`<uses-permission android:name="android.permission.VIBRATE"/>` 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.
|
||||
200
dopamine-ux/verify_prompts.py
Normal file
200
dopamine-ux/verify_prompts.py
Normal file
@ -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())
|
||||
Loading…
Reference in New Issue
Block a user