diff --git a/.claude/rules/feature-flow.md b/.claude/rules/feature-flow.md new file mode 100644 index 00000000..2104b0c1 --- /dev/null +++ b/.claude/rules/feature-flow.md @@ -0,0 +1,78 @@ +--- +paths: + # Workspace-root paths, written plainly like landing-page.md's `scripts/ui-review/**`: + # the audit's glob check requires a slash before a `**/` prefix, so `**/scripts/…` + # matches nothing at the root. A worktree session's project root IS the worktree, so + # the plain form fires there too. + - "scripts/ui-review/deck/**" + - "scripts/ui-review/review-cards.py" + - "scripts/ui-review/contract-agent.md" + - "docs/active/design/**" + - "scripts/close-out.sh" +last_verified: 2026-09-01 +verify: + - path: scripts/ui-review/deck/contract.py + contains: "def check_contract" + - path: scripts/ui-review/contract-agent.md + - test: scripts/ui-review/tests/test_contract.py + - test: scripts/ui-review/tests/test_words.py +--- + +# Feature flow — the deck is the one surface + +Design: `docs/active/specs/2026-09-01-feature-flow-design.md`. + +## Questions before drawing +**Invariant:** a new feature's step-2 questions are a words-only deck (`.questions.json`, +`"words": true` decide steps, 1–3 options), served and submitted before any UI is drawn. A note +with no tag (answers files from before 2026-09-01) counts as **just noting**, same as a tagged one. +(untagged-note rule: `scripts/ui-review/contract-agent.md` prose — none — candidate) +**Why:** answers in chat are not a source; a contract row must resolve to an answered step. +**Guard:** `test_words.py` covers the words-deck invariant only; the `ui-mockup` skill's +"Before drawing anything" section (prose — not enforced). + +## The contract is a deck, and its sources are answered steps +**Invariant:** `.contract.json` is a one-step `rows` deck; every row's `source` is +`#` of a submitted, non-skipped answer. Not the design spec, not the plan, +not the transcript. Written by a FRESH agent from `scripts/ui-review/contract-agent.md`. +**Why:** provenance — the rows are Destin's decisions, and a generator grading itself is generous. +**Guard:** `review-cards.py contract-check`; `test_contract.py`. + +## Answers files are committed +**Invariant:** `docs/**/*.answers.json` (and the stamped rotations) are tracked; only `scratch/` +is ignored. Never add them back to `.gitignore`. +**Why:** they are the only record of decisions; ignored for three months, they lived on one disk. +**Guard:** none — candidate (an anchor test on `.gitignore`). + +## Reopen only through a deck +**Invariant:** when implementation contradicts approved UI, the implementing session serves a +one-step words-only `decide` deck and waits; a chat question is not a route back. The answer +amends the contract row's `source`. +**Why:** a chat answer is not a source (see above). +**Guard:** none — candidate. + +## The gate is three facts, and one command reports them +**Invariant:** `review-cards.py contract-check .contract.json` is the only reader of +the gate: (1) every row's source resolves and every `mechanical` guard exists on disk or on +the contract's `branch` (exit 1 otherwise); (2) the contract was signed — `.contract.answers.json` +submitted with the contract step `yes`; (3) `.contract.acceptance.answers.json` is +submitted. `close-out.sh` relays its `ok:` / `todo:` lines and reads no answers file itself. +**Why:** a guard the branch adds is not in the main checkout until merge; a contract nobody +signed is not a definition of done; two readers of one file drift. +**Guard:** `test_contract.py` (ContractCheckTests); `close-out-contract.test.sh`. + +## The build stage is reviewed, capped, and recorded +**Invariant:** between the signed contract and the branch: a technical design → reviewer rounds +that each write `docs/active/reviews/--design-review-.md` (findings `R-` +marked accepted / rejected / already handled, reversals tagged `reverses:`), stopping on a round +with nothing accepted, cap three → task breakdown (descriptions by default; pre-written code +only for cross-repo / stored-data / strict-order work) → subagent build with a reviewer per task. +**Why:** nothing yet shows whether review rounds improve a design or churn it; the files are the data. +**Guard:** none — candidate (design §8b: tooling after three features). + +## Acceptance is graded rows plus human rows +**Invariant:** the grader writes `.contract.verdicts.json` (beside the contract, same +stem — the CLI reads exactly that name); `review-cards.py acceptance` refuses when a +`mechanical` or `deck` row has no verdict. +**Why:** an ungraded row is not a pass. +**Guard:** `test_contract.py` (AcceptanceTests). diff --git a/.claude/skills/ui-mockup/SKILL.md b/.claude/skills/ui-mockup/SKILL.md index 5becf120..550a8483 100644 --- a/.claude/skills/ui-mockup/SKILL.md +++ b/.claude/skills/ui-mockup/SKILL.md @@ -10,6 +10,16 @@ approved changes — see `docs/archive/specs/2026-07-16-ui-consistency-design-sp output format it produced). That process still holds. What changed on 2026-07-29 is **where the rendering happens**. +## Before drawing anything: the questions deck + +Step 2 of the feature flow (`docs/active/specs/2026-09-01-feature-flow-design.md` §5) is a +deck, not a chat. Write `docs/active/design/-/.questions.json` — one +`"words": true` step per question, one to three options (the recommended one first, its why in +`summary`), no picture — and `serve` it in the background. Do not ask what the design guide or +the code already answers; do not ask what has an obvious answer (state it, the review deck +will show it). Draw only after it is submitted: its answers are the first source of the +contract. + ## The mechanism: edit the real components `bash scripts/run-workbench.sh` boots the **real renderer** in a browser tab at @@ -76,14 +86,25 @@ feel right" — is his, and he can usually eyeball it in 30 seconds. Tell him wh ## After approval -Decisions must not live only in chat: +Decisions must not live only in chat — and the deck answers ARE the record (they are committed): -1. Capture them in a spec under `docs/active/specs/` — ledger, the surfaces touched, migration - notes. +1. **Write the contract.** Dispatch a fresh agent with `scripts/ui-review/contract-agent.md`, + the questions deck, every round's spec and answers, and the branch name. Serve + `.contract.json`; it is the last thing Destin answers before the build. Run + `review-cards.py contract-check` on it and paste the output into the handoff. 2. Turn the `MOCK_ONLY` entries the approved UI depends on into real handlers (main + `preload.ts` + `remote-shim.ts` + `SessionService.kt`, guarded by `ipc-channels.test.ts`), then drop them from the registry. -3. Add ROADMAP entries for anything deferred, and follow the workspace knowledge rules - (pinning test > ast-grep rule > WHY comment > path-scoped rule) for anything durable. +3. **Run the build stage** (design §8): a short technical design (backend, data shape, reuse) + → adversarial review, one findings file per round under `docs/active/reviews/`, stop on a + round with nothing accepted, cap three → task breakdown, descriptions by default and + pre-written code only for cross-repo / stored-data / strict-order work → subagent-driven + build with a reviewer per task. Destin is not in this stage; a contradiction with the + approved UI is a reopen deck, never a silent change. +4. Add ROADMAP entries for every *fix later* note the contract agent listed, and follow the + workspace knowledge rules (pinning test > ast-grep rule > WHY comment > path-scoped rule). +5. At the end: write `.contract.verdicts.json` beside the contract, run + `review-cards.py acceptance`, serve the acceptance deck; `bash scripts/close-out.sh ` + reports whether the contract holds, was signed, and was accepted. Merging cannot shift appearance, because nothing was ever copied. diff --git a/.github/workflows/workspace-ci.yml b/.github/workflows/workspace-ci.yml index e9df0a48..21d5b5d7 100644 --- a/.github/workflows/workspace-ci.yml +++ b/.github/workflows/workspace-ci.yml @@ -93,18 +93,19 @@ jobs: # (`-t .` cannot import a directory with no __init__.py). Same shape as the # hooks above: a check that stops checking goes quiet, not red. # - # ONLY these three suites. Every other one shells out to `magick` (test_boxes, + # ONLY these five suites. Every other one shells out to `magick` (test_boxes, # test_build, test_crops, test_cli, test_serve) or drives Chrome/ffmpeg (the # three *.test.mjs), none of which this runner has — so they stay local and - # scripts/ui-review/README.md says so. test_live is written picture-free on - # purpose to keep the new coverage on this side of that line. + # scripts/ui-review/README.md says so. test_live, test_words and test_contract + # are written picture-free on purpose to keep the new coverage on this side of + # that line. # # working-directory, not `-t`: the suites live outside a package, so each one # bootstraps its own sys.path and must be imported as a top-level module. - name: Test the review deck if: ${{ !cancelled() }} working-directory: scripts/ui-review/tests - run: python3 -m unittest -v test_spec test_tokens test_live + run: python3 -m unittest -v test_spec test_tokens test_live test_words test_contract # A command printed in a doc is a promise nobody checks. The deck's test command sat # WRONG in two docs for months — `-t .` cannot start at all — which is why that suite diff --git a/.gitignore b/.gitignore index c66dcea7..ca16d09f 100644 --- a/.gitignore +++ b/.gitignore @@ -109,8 +109,8 @@ docs/active/design/*-ui-audit/images/ # (scratch/ already covers the fixture HOME, the downloaded engine/model assets and # the per-boot desktop.log copies.) perf-reports/shots/ -*.answers.json -*.answers.*.json +# Deck answers (*.answers.json) are Destin's decisions and are COMMITTED (feature-flow design §2). +# Throwaway decks live in scratch/, which is ignored above. `*.serve.json` is a runtime lock. *.serve.json # Python bytecode from the deck tooling + its tests diff --git a/CLAUDE.md b/CLAUDE.md index f9d6b4f7..63ab75d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ bash scripts/run-dev.sh --label "Feature Name" ### New Features & UI/UX Changes -When designing new features or making changes to user-facing app interfaces, the first step should always be to visualize and design the UI/UX of the final feature. Planning sessions should prioritize iterative UI design using the workbench and other tooling to help Destin shape the final user experience of the feature before building backend. When Destin provides final sign-off on the UI/UX design for the feature, the UI/UX should be treated as largely final and backend should be designed around the UI/UX accordingly. The standard every new surface is measured against is `docs/active/design/2026-08-25-ui-design-guide.md` (five laws, primitives, per-surface anatomies, checklist); show him the change as a **review deck** (scripts/ui-review/review-cards.py — one point per step: Before | After with the changed region boxed by the rig, a headline and three cards — What changed / You'll notice / Risk — Yes / No / Other, answers saved to a file and handed to Claude on Submit; `serve ` in the background does it all), built from the UI review rig below; never a gallery, a prose page or a chat description (all three were rejected). **For motion, drag or hover, use a LIVE step** — panes of the running app he can actually operate, one authored candidate each out of `youcoded`'s `compare/registry.tsx` (`serve` boots the worktree's workbench for them). A recording is the wrong tool for a 200 ms animation: four clip steps were rejected on 2026-08-31 as "just rough to compare". `scripts/ui-review/README.md` → "Live panes". +When designing new features or making changes to user-facing app interfaces, the first step should always be to visualize and design the UI/UX of the final feature. Planning sessions should prioritize iterative UI design using the workbench and other tooling to help Destin shape the final user experience of the feature before building backend. When Destin provides final sign-off on the UI/UX design for the feature, the UI/UX should be treated as largely final and backend should be designed around the UI/UX accordingly. The standard every new surface is measured against is `docs/active/design/2026-08-25-ui-design-guide.md` (five laws, primitives, per-surface anatomies, checklist); show him the change as a **review deck** (scripts/ui-review/review-cards.py — one point per step: Before | After with the changed region boxed by the rig, a headline and three cards — What changed / You'll notice / Risk — Yes / No / Other, answers saved to a file and handed to Claude on Submit; `serve ` in the background does it all), built from the UI review rig below; never a gallery, a prose page or a chat description (all three were rejected). The flow around the deck — questions deck first, contract at sign-off, acceptance deck at the end, then the build stage (technical design → capped review → task breakdown → subagent build) — is `.claude/rules/feature-flow.md`. **This overrides the brainstorming skill's habit of asking in chat:** on a YouCoded feature, its opening questions go on a questions deck (`ui-mockup` skill → "Before drawing anything"), and a chat answer is not a source for the contract. **For motion, drag or hover, use a LIVE step** — panes of the running app he can actually operate, one authored candidate each out of `youcoded`'s `compare/registry.tsx` (`serve` boots the worktree's workbench for them). A recording is the wrong tool for a 200 ms animation: four clip steps were rejected on 2026-08-31 as "just rough to compare". `scripts/ui-review/README.md` → "Live panes". ### UI Workbench diff --git a/ROADMAP.md b/ROADMAP.md index 6f96892a..5d12d33e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -858,6 +858,10 @@ surface, not a history. ## Features +- [ ] **Feature flow: questions deck → review rounds → contract → acceptance, with contract-check in close-out** — design `docs/active/specs/2026-09-01-feature-flow-design.md`, plan `docs/active/plans/2026-09-01-feature-flow-plan.md`. All four §9 assumptions answered as recommended on 2026-09-02 (§8 rewritten 2026-09-02 as the build stage: technical design → capped, self-recording review → task breakdown → subagent build). `feature` `#workspace` `#ui-review` (added 2026-09-01) +- [ ] Measure the design-review loop (design §8b): after three features have run through it, count accepted findings, reversals (`reverses:`) and defect-vs-taste per round from `docs/active/reviews/*-design-review-*.md`, and set the default round count from the numbers — today nobody knows whether round three improves a design or churns it. Tooling (a findings-file parser, a stop-rule check) waits for that data. `idea` `#workspace` `#ui-review` (added 2026-09-02) +- [ ] Feature-flow coverage debt left by the 2026-09-01 final review: no browser test for the contract table (`rowsTable`, verdict column, pass/fail tint); `close-out-contract.test.sh` is local-only so the Contract section has no unattended guard; no pinning test for `verdict: ''` vs an absent key, a two-`#` source, a corrupt verdicts file or a missing `git`; `close-out.sh` reads `rg` exit 1 and 2 alike (shared by its three older `rg` calls). None is a known failure. `idea` `#ui-review` `#tests` (added 2026-09-01) + - [ ] **Agents & Automations view — work that runs on a schedule or trigger without the user, with an inbox** `feature` `#agents` `#automations` `#native-runtime` (added 2026-09-01, backfilled — designed 2026-07-09 as platform-vision Phase 4, never tracked here) A third top-level view beside Chat and Projects. Triggers v1 = "Run now" + cron/one-time; runner = a main-process scheduler with a persisted, restart-surviving job store spawning headless harness sessions under step/token/time/**cost** budgets that are hard stops; inbox = run states `scheduled / running / needs-approval / completed / failed`, status-bar chip, notifications, Android/remote push later; a run's transcript is an ordinary read-only session and its files land in the artifact viewer. Backend-agnostic from day one (local model = free 24/7, OpenRouter, or Claude Code headless). Exit criterion: "every morning at 8, summarize my project's new GitHub issues into a note and ping me if any look urgent" is creatable by a non-developer and its runs appear in an inbox. **Verified 2026-09-01: zero scheduling/trigger/automation code exists in either app.** Blocked on the "Assistants made of Duties" ruling (Someday, below) — the unit of organization decides what a manifest even is — plus cost accounting and specialists stage two's durable journal. The 2026-09-01 roadmap-restructure taxonomy has no area for this family yet. Full shape: `docs/active/specs/2026-09-01-agent-platform-vision-and-state.md` §6.1. diff --git a/docs/MAP.md b/docs/MAP.md index da0852e0..cd9d58e6 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -16,7 +16,7 @@ Rules live in `.claude/rules/`; depth docs are read-on-demand (`youcoded/docs/`, | IPC bridge (parity) | `youcoded/desktop/src/main/preload.ts`
`youcoded/desktop/src/renderer/remote-shim.ts`
`youcoded/app/src/main/kotlin/com/youcoded/app/runtime/SessionService.kt` | ipc-bridge | `youcoded/docs/shared-ui-architecture.md` | `youcoded/desktop/tests/ipc-channels.test.ts` | | React renderer / chrome | `youcoded/desktop/src/renderer/App.tsx`
`youcoded/desktop/src/renderer/components/HeaderBar.tsx`
`youcoded/desktop/src/renderer/styles/globals.css` | react-renderer | `youcoded/docs/renderer-chrome.md` | `youcoded/desktop/tests/overlay-layer-authority.test.ts`
`youcoded/desktop/tests/type-scale-authority.test.ts` | | UI Workbench (dev-only) | `youcoded/desktop/src/renderer/dev/workbench/`
`youcoded/desktop/src/renderer/index.tsx` (boot branch)
`scripts/run-workbench.sh` | react-renderer | `docs/archive/specs/2026-07-29-ui-workbench-design.md` | `youcoded/desktop/tests/workbench-mock-contract.test.ts`
`youcoded/desktop/tests/workbench-channels.test.ts`
`youcoded/desktop/tests/workbench-shim-semantics.test.ts` | -| UI review rig (dev-only) | `scripts/ui-review/run-review.sh` (sweep)
`scripts/ui-review/shot.mjs` (self-verifying CDP driver)
`scripts/ui-review/plans/` (what opens what)
`scripts/ui-review/review-cards.py` + `scripts/ui-review/deck/` (the review deck: `build` / `serve` / `wait`)
`scripts/ui-review/deck/live.py` (live panes: the pane address and who owns port 5513) | react-renderer | `scripts/ui-review/README.md` · `docs/active/design/2026-08-25-ui-design-guide.md` | `cd scripts/ui-review/tests && python3 -m unittest test_spec test_tokens test_live` (also in `workspace-ci.yml`); the rest need `magick`/Chrome — `python3 -m unittest discover -s scripts/ui-review/tests -t scripts/ui-review/tests -p 'test_*.py'` + `node --test scripts/ui-review/tests/deck-render.test.mjs`; `coverage.md` from the last sweep (103/104 on 2026-08-25); `scripts/workbench-boot-check.mjs` guards the switches the plans use | +| UI review rig (dev-only) | `scripts/ui-review/run-review.sh` (sweep)
`scripts/ui-review/shot.mjs` (self-verifying CDP driver)
`scripts/ui-review/plans/` (what opens what)
`scripts/ui-review/review-cards.py` + `scripts/ui-review/deck/` (the review deck: `build` / `serve` / `wait` / `contract-check` / `acceptance`)
`scripts/ui-review/deck/live.py` (live panes: the pane address and who owns port 5513) | react-renderer · feature-flow | `scripts/ui-review/README.md` · `docs/active/design/2026-08-25-ui-design-guide.md` | `cd scripts/ui-review/tests && python3 -m unittest test_spec test_tokens test_live test_words test_contract` (also in `workspace-ci.yml`); the rest need `magick`/Chrome — `python3 -m unittest discover -s scripts/ui-review/tests -t scripts/ui-review/tests -p 'test_*.py'` + `node --test scripts/ui-review/tests/deck-render.test.mjs`; `coverage.md` from the last sweep (103/104 on 2026-08-25); `scripts/workbench-boot-check.mjs` guards the switches the plans use | | Landing page + demo clips (itsdestin.github.io/youcoded) | `youcoded/docs/index.html` (the site)
`scripts/ui-review/site-assets.sh` (regenerate every loop/still/embed)
`scripts/ui-review/record.mjs` + `scripts/ui-review/scenes/` (one JSON per clip)
`scripts/ui-review/copy-preview.py` (in-place copy + loop review)
`youcoded/desktop/src/renderer/dev/workbench/fixtures/replies/` (what the demo "model" says) | landing-page | `scripts/ui-review/README.md` → "Recording a loop" · `docs/archive/specs/2026-08-27-landing-page-rebuild-design.md` | `workbench-reply-script`, `workbench-fixture-actions`, `workbench-mock-contract` tests; `site-assets.sh` refuses unverified shots | | Perf lab / stress suite (dev-only) | `scripts/perf-lab/run.mjs` (one command, one JSON report)
`scripts/perf-lab/scenario-idle.mjs` and its siblings (per-surface scenarios)
`scripts/perf-lab/probe-ipc.mjs` (main-process stall detector)
`youcoded/desktop/src/main/perf-marks.ts` (the app-side marks it parses) | (none — workspace tool) | `scripts/perf-lab/README.md` · `docs/active/handoffs/2026-08-27-perf-lab-session-status.md` | `node --test scripts/perf-lab/tests/*.test.mjs` (168 tests; **`node --test /` fails on Node 26**)
`youcoded/desktop/tests/perf-marks-placement.test.ts` pins the mark names the rig parses | | Session close-out + workspace retrospective (dev-only) | `scripts/close-out.sh` (per-branch, two modes: pre-merge vs post-merge)
`.claude/skills/wrap-up/SKILL.md` (the end-of-session procedure)
`scripts/audit-anchors.mjs` (the machine-checkable half) | (none — workspace tool) | `CLAUDE.md` → Ending a Session · `.claude/commands/audit.md` | `node --test scripts/audit-anchors.test.mjs` (41 cases; **`node --test /` fails on Node 26**)
`close-out.sh` is exercised by running it against a merged and an unmerged branch | diff --git a/docs/active/design/2026-08-25-ui-audit/phase-d-brief.answers.json b/docs/active/design/2026-08-25-ui-audit/phase-d-brief.answers.json new file mode 100644 index 00000000..90c7131b --- /dev/null +++ b/docs/active/design/2026-08-25-ui-audit/phase-d-brief.answers.json @@ -0,0 +1,61 @@ +{ + "deck": "phase-d-brief", + "started": "2026-08-27T10:57:07.280Z", + "submitted": "2026-08-27T11:06:44Z", + "cur": 7, + "answers": { + "P-6.1": { + "v": "yes", + "seconds": 149, + "theme": "midnight", + "zoom": 1, + "note": "i think we should actually add a full frame around the welcome screen, as exists in terminal view, with settings/projects/minimize/maximize/close. no sessions switch on welcome screen obvsiously, and no status bar chips or chat bar. just bare frame like terminal view" + }, + "P-19.1": { + "v": "other", + "seconds": 159, + "theme": "midnight", + "zoom": 1, + "note": "i think i will want a few mockups of this. consider how these chips are handled for various filetypes, and how to handle all. name should be small strip at bottom of card and we should render a preview for most filetypes probably if cheap. or at least for basic image/markdown/etc" + }, + "P-14.1": { + "v": "yes", + "seconds": 17, + "theme": "midnight", + "zoom": 1 + }, + "P-9.1": { + "v": "yes", + "seconds": 41, + "theme": "midnight", + "zoom": 1 + }, + "P-9.2": { + "v": "no", + "note": "should still show names/action on hover tho", + "seconds": 80, + "theme": "midnight", + "zoom": 1 + }, + "P-9.3": { + "v": "yes", + "seconds": 20, + "theme": "midnight", + "zoom": 1 + }, + "P-20.1": { + "v": "other", + "note": "this may be a testing environment bug. that terminal is also too short, but i haven't experienced that in my app.", + "seconds": 45, + "theme": "golden-sunbreak", + "zoom": 1 + }, + "P-20.2": { + "v": "other", + "note": "i'd want to see this rendered a few ways before committing to anything.", + "seconds": 45, + "theme": "golden-sunbreak", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-25-ui-audit/phase-d-mockups.answers.json b/docs/active/design/2026-08-25-ui-audit/phase-d-mockups.answers.json new file mode 100644 index 00000000..f79a0cd5 --- /dev/null +++ b/docs/active/design/2026-08-25-ui-audit/phase-d-mockups.answers.json @@ -0,0 +1,66 @@ +{ + "deck": "phase-d-mockups", + "started": "2026-08-27T20:39:37.814Z", + "submitted": "2026-08-27T20:53:32Z", + "cur": 1, + "answers": { + "P-19.A": { + "note": "no, you got rid of the thumbnails/previews which are core to the design. try again.", + "v": "other", + "seconds": 43, + "theme": "midnight", + "zoom": 1 + }, + "P-19.B": { + "v": "skip", + "seconds": 32, + "theme": "meadow-mist", + "zoom": 1 + }, + "P-20.solid100": { + "v": "skip", + "seconds": 7, + "theme": "meadow-mist", + "zoom": 1 + }, + "P-20.solid90": { + "v": "skip", + "seconds": 26, + "theme": "meadow-mist", + "zoom": 1 + }, + "P-20.scrim": { + "v": "skip", + "seconds": 25, + "theme": "meadow-mist", + "zoom": 1 + }, + "P-20.today": { + "v": "skip", + "seconds": 18, + "theme": "meadow-mist", + "zoom": 1 + }, + "P-19.C": { + "v": "skip", + "seconds": 15, + "theme": "meadow-mist", + "zoom": 1 + }, + "P-20.2": { + "v": "other", + "seconds": 36, + "theme": "meadow-mist", + "zoom": 1, + "note": "kinda confused about the difference between scrim and stronger panel. what are these and what is the difference." + }, + "P-19": { + "v": "pick", + "pick": "C", + "note": "we should make all tiles (here and in project view/elsewhere where they show up) render with proper markdown styling instead of be being able to see the ## and whatever else.", + "seconds": 67, + "theme": "meadow-mist", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-25-ui-audit/phase-d-review.answers.json b/docs/active/design/2026-08-25-ui-audit/phase-d-review.answers.json new file mode 100644 index 00000000..d43c0e87 --- /dev/null +++ b/docs/active/design/2026-08-25-ui-audit/phase-d-review.answers.json @@ -0,0 +1,39 @@ +{ + "deck": "phase-d-review", + "started": "2026-08-27T20:37:41.728Z", + "submitted": "2026-08-27T20:39:30Z", + "cur": 4, + "answers": { + "P-6.1": { + "v": "yes", + "seconds": 27, + "theme": "midnight", + "zoom": 1 + }, + "P-14.1": { + "v": "other", + "note": "not sure i like this. i still want it to be its own element/pill with border, i just don't want it to overlap my message. ", + "seconds": 34, + "theme": "midnight", + "zoom": 1 + }, + "P-9.1": { + "v": "yes", + "seconds": 12, + "theme": "midnight", + "zoom": 1 + }, + "P-9.3": { + "v": "yes", + "seconds": 5, + "theme": "midnight", + "zoom": 1 + }, + "P-9.3b": { + "v": "yes", + "seconds": 28, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-25-ui-audit/phase-d-review2.answers.json b/docs/active/design/2026-08-25-ui-audit/phase-d-review2.answers.json new file mode 100644 index 00000000..b32fd1af --- /dev/null +++ b/docs/active/design/2026-08-25-ui-audit/phase-d-review2.answers.json @@ -0,0 +1,14 @@ +{ + "deck": "phase-d-review2", + "started": "2026-08-27T21:37:01.718Z", + "submitted": "2026-08-27T21:37:07Z", + "cur": 0, + "answers": { + "P-14.1": { + "v": "yes", + "seconds": 5, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-25-ui-audit/phase-d-review3.answers.json b/docs/active/design/2026-08-25-ui-audit/phase-d-review3.answers.json new file mode 100644 index 00000000..1b9f05cd --- /dev/null +++ b/docs/active/design/2026-08-25-ui-audit/phase-d-review3.answers.json @@ -0,0 +1,20 @@ +{ + "deck": "phase-d-review3", + "started": "2026-08-27T21:36:04.692Z", + "submitted": "2026-08-27T21:36:48Z", + "cur": 1, + "answers": { + "P-19.1": { + "v": "yes", + "seconds": 29, + "theme": "midnight", + "zoom": 1 + }, + "P-20.2": { + "v": "yes", + "seconds": 12, + "theme": "meadow-mist", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-25-ui-audit/phase-e-brief.answers.json b/docs/active/design/2026-08-25-ui-audit/phase-e-brief.answers.json new file mode 100644 index 00000000..39fc2b21 --- /dev/null +++ b/docs/active/design/2026-08-25-ui-audit/phase-e-brief.answers.json @@ -0,0 +1,33 @@ +{ + "deck": "phase-e-brief", + "started": "2026-08-28T01:56:50.970Z", + "submitted": "2026-08-28T01:58:44Z", + "cur": 3, + "answers": { + "P-7.1": { + "v": "yes", + "seconds": 19, + "theme": "midnight", + "zoom": 1 + }, + "P-8.1": { + "note": "sessions should stick to 1 line, project should go below on second line, truncate with .... and show full name on hover. ", + "v": "yes", + "seconds": 64, + "theme": "midnight", + "zoom": 1 + }, + "P-4.1": { + "v": "no", + "seconds": 12, + "theme": "midnight", + "zoom": 1 + }, + "P-4.2": { + "v": "no", + "seconds": 17, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-25-ui-audit/phase-e-review.answers.json b/docs/active/design/2026-08-25-ui-audit/phase-e-review.answers.json new file mode 100644 index 00000000..0384d06a --- /dev/null +++ b/docs/active/design/2026-08-25-ui-audit/phase-e-review.answers.json @@ -0,0 +1,21 @@ +{ + "deck": "phase-e-review", + "started": "2026-08-28T02:17:05.003Z", + "submitted": "2026-08-28T02:20:33Z", + "cur": 1, + "answers": { + "P-7.1": { + "v": "yes", + "seconds": 14, + "theme": "midnight", + "zoom": 1 + }, + "P-8.1": { + "note": "we should left-align title/project. instead of current dot, i want a new dot+text in a pill that says Working/Inactive/Response Ready/Needs Input. should be right side in-line with title. we should add a small file icon before the project name and tag icons on the right side under the activity indicator. activity indicator pill should all be translucent-ish and color of dot.", + "v": "yes", + "seconds": 193, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-25-ui-audit/phase-e-review2.answers.json b/docs/active/design/2026-08-25-ui-audit/phase-e-review2.answers.json new file mode 100644 index 00000000..5393788f --- /dev/null +++ b/docs/active/design/2026-08-25-ui-audit/phase-e-review2.answers.json @@ -0,0 +1,22 @@ +{ + "deck": "phase-e-review2", + "started": "2026-08-28T02:52:46.508Z", + "submitted": "2026-08-28T02:54:13Z", + "cur": 1, + "answers": { + "P-8.2": { + "v": "yes", + "note": "tags should not be dots, but full chips with spelled names", + "seconds": 22, + "theme": "midnight", + "zoom": 1 + }, + "P-8.3": { + "v": "pick", + "pick": "A", + "seconds": 63, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-25-ui-audit/phase-e-review3.answers.json b/docs/active/design/2026-08-25-ui-audit/phase-e-review3.answers.json new file mode 100644 index 00000000..0cdfac46 --- /dev/null +++ b/docs/active/design/2026-08-25-ui-audit/phase-e-review3.answers.json @@ -0,0 +1,14 @@ +{ + "deck": "phase-e-review3", + "started": "2026-08-28T03:35:37.069Z", + "submitted": "2026-08-28T03:36:11Z", + "cur": 0, + "answers": { + "P-8.4": { + "v": "yes", + "seconds": 25, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-27-chatsearch-refs-gate/chatsearch-gate.answers.json b/docs/active/design/2026-08-27-chatsearch-refs-gate/chatsearch-gate.answers.json new file mode 100644 index 00000000..61623726 --- /dev/null +++ b/docs/active/design/2026-08-27-chatsearch-refs-gate/chatsearch-gate.answers.json @@ -0,0 +1,122 @@ +{ + "deck": "Chat Search Preview Gate", + "started": "2026-08-27T22:57:04.069Z", + "submitted": "2026-08-27T23:13:11Z", + "cur": 16, + "answers": { + "D2-expand": { + "v": "other", + "seconds": 96, + "theme": "midnight", + "zoom": 1, + "note": "why are there two cards/types?" + }, + "M-row": { + "v": "other", + "seconds": 84, + "theme": "midnight", + "zoom": 1, + "note": "replace circle/checkmark on this card with chat bubble thing. remove \"raw output\" and instead add some indicator of what the model actually searched for/read from the conversations." + }, + "M-states": { + "note": "should just hide dead. need to make sure all these buttons are hover-sensitive and match otehr app themeing, tooltip for greyed buttons.", + "v": "yes", + "seconds": 81, + "theme": "midnight", + "zoom": 1 + }, + "M-blocked": { + "v": "yes", + "seconds": 6, + "theme": "midnight", + "zoom": 1 + }, + "M-tombstone": { + "v": "yes", + "note": "resume looks active in this screenshot row tho, may be a bug.", + "seconds": 36, + "theme": "midnight", + "zoom": 1 + }, + "M-piped": { + "v": "yes", + "seconds": 15, + "theme": "midnight", + "zoom": 1 + }, + "M-show": { + "note": "all searches in a turn should group into a single card. then a separate \"display/reference conversation\" method/mechanism for assistant to intentional present conversations in a table mid chat-bubble for the user. This project is blah blah, working on blah balh, see: [convo 1] /n [convo 2] /n [convo 3]. etc. This other project is blah, working on blah: convo 4. etc. \"display\" is separate from search, \"display\" is a renderer/parser trick probably while \"search\" is a tool and all \"search\" calls in one turn group together. \"search\" tool result can include the guidance to display chosen results as part of a message in renderer's specific format.", + "v": "other", + "seconds": 202, + "theme": "midnight", + "zoom": 1 + }, + "M-header": { + "v": "yes", + "note": "i want resume to be between expand and X button. i also want it to open a brief little popup that offers model/skip permissions choices if relevant for claude/native sessions and a final resume confirm menu. same as resume menus used elsewhere.", + "seconds": 108, + "theme": "midnight", + "zoom": 1 + }, + "M-toolgap": { + "note": "should style as a \"3 tools\" toolcard thing in the assistant bubbles, styled like real tool cards/groups in chat", + "v": "other", + "seconds": 59, + "theme": "midnight", + "zoom": 1 + }, + "M-caption": { + "note": "remove \"past conversation. put read-only to the right of the assistant.", + "v": "yes", + "seconds": 65, + "theme": "midnight", + "zoom": 1 + }, + "M-loadolder": { + "v": "yes", + "seconds": 7, + "theme": "midnight", + "zoom": 1 + }, + "M-tags": { + "v": "yes", + "seconds": 10, + "theme": "midnight", + "zoom": 1 + }, + "M-rightclick": { + "v": "yes", + "seconds": 13, + "theme": "midnight", + "zoom": 1 + }, + "D1-referenced": { + "v": "pick", + "pick": "a", + "note": "just show title in this list, not assistant or whateva.", + "seconds": 38, + "theme": "midnight", + "zoom": 1 + }, + "M-error": { + "v": "yes", + "seconds": 20, + "theme": "midnight", + "zoom": 1 + }, + "M-narrow": { + "note": "make it a chat bubble icon of some sort", + "v": "yes", + "seconds": 50, + "theme": "midnight", + "zoom": 1 + }, + "D4-present": { + "note": "kinda explained earlier. we may be changing this look a bit to my new table idea thing. parse bubbles for formatted text basically", + "v": "other", + "seconds": 76, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck-r2.answers.json b/docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck-r2.answers.json new file mode 100644 index 00000000..662f744d --- /dev/null +++ b/docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck-r2.answers.json @@ -0,0 +1,52 @@ +{ + "deck": "marketplace-overhaul-r2", + "started": "2026-08-28T02:06:54.511Z", + "submitted": "2026-08-28T02:10:08Z", + "cur": 6, + "answers": { + "one-row": { + "v": "yes", + "seconds": 19, + "theme": "midnight", + "zoom": 1 + }, + "connections": { + "v": "yes", + "seconds": 5, + "theme": "midnight", + "zoom": 1 + }, + "likely-safe": { + "note": "instead of 412 installs, we should just show a download icon next to 412. i think the star should be in-line with/right beside \"INSTALLED\"", + "v": "yes", + "seconds": 91, + "theme": "midnight", + "zoom": 1 + }, + "card-anatomy": { + "v": "yes", + "seconds": 19, + "theme": "midnight", + "zoom": 1 + }, + "split-cards": { + "v": "yes", + "seconds": 18, + "theme": "midnight", + "zoom": 1 + }, + "phone-sheet": { + "v": "yes", + "seconds": 10, + "theme": "midnight", + "zoom": 1 + }, + "detail-badges": { + "v": "yes", + "note": "on all of these surface, we should make author a chip like likeyl safe/youcoded.", + "seconds": 31, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck-r3.answers.json b/docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck-r3.answers.json new file mode 100644 index 00000000..483b1483 --- /dev/null +++ b/docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck-r3.answers.json @@ -0,0 +1,26 @@ +{ + "deck": "marketplace-overhaul-r3", + "started": "2026-08-28T02:40:23.006Z", + "submitted": "2026-08-28T02:41:23Z", + "cur": 2, + "answers": { + "star-and-installs": { + "v": "yes", + "seconds": 29, + "theme": "meadow-mist", + "zoom": 1 + }, + "author-chip": { + "v": "yes", + "seconds": 16, + "theme": "meadow-mist", + "zoom": 1 + }, + "author-chip-detail": { + "v": "yes", + "seconds": 14, + "theme": "meadow-mist", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck.answers.json b/docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck.answers.json new file mode 100644 index 00000000..704ab23d --- /dev/null +++ b/docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck.answers.json @@ -0,0 +1,75 @@ +{ + "deck": "marketplace-overhaul-r1", + "started": "2026-08-28T01:46:19.701Z", + "submitted": "2026-08-28T01:53:24Z", + "cur": 9, + "answers": { + "type-switch": { + "v": "yes", + "note": "i would like to keep the full container thing to a single row. we should collaps some of the other filter toggle things into dropdowns or something", + "seconds": 54, + "theme": "midnight", + "zoom": 1 + }, + "card-trust": { + "note": "checked should be a grey shield icon thing with a check and read \"Likely Safe\" with a hover or click explanation or something. i think we can improve the layout of these cards a bit, not immediately clear what the glober/key icons mean and a lot of stuck is still getting cut of or not visually appealing/semmetrical", + "v": "other", + "seconds": 107, + "theme": "midnight", + "zoom": 1 + }, + "card-bottom": { + "v": "other", + "note": "see prior response", + "seconds": 17, + "theme": "midnight", + "zoom": 1 + }, + "split-view": { + "v": "other", + "note": "see prior response, need to clean up cards a tad", + "seconds": 37, + "theme": "midnight", + "zoom": 1 + }, + "search-split": { + "v": "yes", + "seconds": 16, + "theme": "midnight", + "zoom": 1 + }, + "detail-trust": { + "v": "yes", + "note": "i like this quite a bit", + "seconds": 20, + "theme": "midnight", + "zoom": 1 + }, + "detail-caution": { + "v": "yes", + "seconds": 37, + "theme": "midnight", + "zoom": 1 + }, + "feedback": { + "v": "yes", + "seconds": 26, + "theme": "midnight", + "zoom": 1 + }, + "phone": { + "v": "other", + "note": "see earlier response, some should collapse to dropdowns. also the type thing expands beyond the edge of the card it seems", + "seconds": 31, + "theme": "midnight", + "zoom": 1 + }, + "tools-name": { + "note": "i think connections for now. this is hard cuz we will eventually also add custom real-tools/harnesses that bypass mcp, but good enough for now.", + "v": "other", + "seconds": 79, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-28-ask-question-other/ask-question-other.answers.json b/docs/active/design/2026-08-28-ask-question-other/ask-question-other.answers.json new file mode 100644 index 00000000..e97485a3 --- /dev/null +++ b/docs/active/design/2026-08-28-ask-question-other/ask-question-other.answers.json @@ -0,0 +1,26 @@ +{ + "deck": "ask-question-other", + "started": "2026-08-28T06:18:06.023Z", + "submitted": "2026-08-28T06:38:23Z", + "cur": 2, + "answers": { + "G2.1": { + "v": "yes", + "seconds": 29, + "theme": "midnight", + "zoom": 1 + }, + "G2.2": { + "v": "yes", + "seconds": 1176, + "theme": "midnight", + "zoom": 1 + }, + "G2.3": { + "v": "yes", + "seconds": 9, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-28-bash-background/bash-background.answers.json b/docs/active/design/2026-08-28-bash-background/bash-background.answers.json new file mode 100644 index 00000000..471aef32 --- /dev/null +++ b/docs/active/design/2026-08-28-bash-background/bash-background.answers.json @@ -0,0 +1,38 @@ +{ + "deck": "bash-background", + "started": "2026-08-28T07:11:21.085Z", + "submitted": "2026-08-28T07:13:30Z", + "cur": 4, + "answers": { + "BG.1": { + "v": "yes", + "seconds": 95, + "theme": "midnight", + "zoom": 1 + }, + "BG.2": { + "v": "yes", + "seconds": 5, + "theme": "midnight", + "zoom": 1 + }, + "BG.3": { + "v": "yes", + "seconds": 10, + "theme": "midnight", + "zoom": 1 + }, + "BG.4": { + "v": "yes", + "seconds": 5, + "theme": "midnight", + "zoom": 1 + }, + "BG.5": { + "v": "yes", + "seconds": 13, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-28-chip-edit/review.answers.json b/docs/active/design/2026-08-28-chip-edit/review.answers.json new file mode 100644 index 00000000..8f962fce --- /dev/null +++ b/docs/active/design/2026-08-28-chip-edit/review.answers.json @@ -0,0 +1,14 @@ +{ + "deck": "chip-edit-review", + "started": "2026-08-28T08:58:46.809Z", + "submitted": "2026-08-28T08:59:03Z", + "cur": 0, + "answers": { + "C-1": { + "v": "yes", + "seconds": 15, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-08-31-session-motion/session-motion.answers.json b/docs/active/design/2026-08-31-session-motion/session-motion.answers.json new file mode 100644 index 00000000..99893fea --- /dev/null +++ b/docs/active/design/2026-08-31-session-motion/session-motion.answers.json @@ -0,0 +1,32 @@ +{ + "deck": "session-motion", + "started": "2026-08-31T22:46:27.417Z", + "submitted": null, + "cur": 2, + "answers": { + "pill-expand": { + "v": "skip", + "seconds": 38, + "theme": "midnight", + "zoom": 1 + }, + "hover": { + "v": "skip", + "seconds": 5, + "theme": "midnight", + "zoom": 1 + }, + "drag": { + "v": "skip", + "seconds": 3, + "theme": "midnight", + "zoom": 1 + }, + "switch": { + "v": "skip", + "seconds": 1, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.answers.202609020452.json b/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.answers.202609020452.json new file mode 100644 index 00000000..a897f67f --- /dev/null +++ b/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.answers.202609020452.json @@ -0,0 +1,39 @@ +{ + "deck": "feature-flow-questions", + "started": "2026-09-02T04:30:29.639Z", + "submitted": "2026-09-02T04:52:57Z", + "cur": 3, + "answers": { + "Q-1": { + "v": "pick", + "pick": "a", + "seconds": 24, + "theme": "midnight", + "zoom": 1 + }, + "Q-2": { + "v": "other", + "note": "i'm a bit confused.", + "note_kind": "noting", + "seconds": 49, + "theme": "midnight", + "zoom": 1 + }, + "Q-3": { + "v": "other", + "note": "i'm not sure. what is the real benefit of the current plan system? do plans actually provide the implementing sessions with useful context? what would be lost with option b , and would that be likely to cause issues?", + "note_kind": "noting", + "seconds": 153, + "theme": "midnight", + "zoom": 1 + }, + "Q-4": { + "note": "confused.", + "v": "other", + "note_kind": "noting", + "seconds": 1121, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.answers.json b/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.answers.json new file mode 100644 index 00000000..660ce670 --- /dev/null +++ b/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.answers.json @@ -0,0 +1,36 @@ +{ + "deck": "feature-flow-questions", + "started": "2026-09-02T10:57:54.594Z", + "submitted": "2026-09-02T10:59:57Z", + "cur": 3, + "answers": { + "Q-1": { + "v": "pick", + "pick": "a", + "seconds": 36, + "theme": "midnight", + "zoom": 1 + }, + "Q-2": { + "v": "pick", + "pick": "a", + "seconds": 6, + "theme": "midnight", + "zoom": 1 + }, + "Q-3": { + "v": "pick", + "pick": "a", + "seconds": 63, + "theme": "midnight", + "zoom": 1 + }, + "Q-4": { + "v": "pick", + "pick": "a", + "seconds": 17, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.html b/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.html new file mode 100644 index 00000000..11646b15 --- /dev/null +++ b/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.html @@ -0,0 +1,645 @@ +Feature flow — four assumptions + + +
+
Review deck
+
+
·
+ + +
+
+
+
+
100%
+
+

+
+ + + + + +
+
+
+
+
+
+

Submit your feedback?

+
Skipped steps are sent as "no answer"; Claude leaves those unchanged.
+ +
+
+ + + diff --git a/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.json b/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.json new file mode 100644 index 00000000..f61db031 --- /dev/null +++ b/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.json @@ -0,0 +1,87 @@ +{ + "title": "Feature flow — four assumptions", + "key": "feature-flow-questions", + "out": "feature-flow.questions.html", + "themes": [ + "midnight", + "light" + ], + "steps": [ + { + "id": "Q-1", + "words": true, + "surface": "Feature flow", + "path": "Design §9", + "headline": "Is a separate acceptance deck at the end one deck too many?", + "options": [ + { + "id": "a", + "label": "Keep the acceptance deck (recommended)", + "summary": "You tick the human rows after the work exists, and see every machine verdict beside them. One extra deck per feature." + }, + { + "id": "b", + "label": "Fold the human rows into the contract", + "summary": "One fewer deck, but you would tick 'this holds' rows before anything is built — a promise, not a check." + } + ] + }, + { + "id": "Q-2", + "words": true, + "surface": "Feature flow", + "path": "Design §9", + "headline": "When the build contradicts an approved design and you do not answer, proceed on a marked default or always stop?", + "options": [ + { + "id": "a", + "label": "Proceed on a marked default (recommended)", + "summary": "The reopen deck names a default; if it times out the build continues and the acceptance deck carries a 'decided without you' row you can veto. This is what makes a one-shot build possible." + }, + { + "id": "b", + "label": "Always stop and wait", + "summary": "Nothing is ever decided for you, but every contradiction is a hard stop until you return." + } + ] + }, + { + "id": "Q-3", + "words": true, + "surface": "Feature flow", + "path": "Design §8c", + "headline": "Build tasks carry a description by default, with pre-written code only for cross-repo, stored-data or strict-order work?", + "options": [ + { + "id": "a", + "label": "Description by default (recommended)", + "summary": "The technical design and its review round happen on every feature either way. Subagents write the code from a task description plus the contract; pre-written code only where getting it wrong up front is costly." + }, + { + "id": "b", + "label": "Always pre-write the code", + "summary": "Every task ships with its code typed out, as the 1,770-line plan for this feature did. Half of that was corrected after review and its line numbers were stale on day one." + } + ] + }, + { + "id": "Q-4", + "words": true, + "surface": "Feature flow", + "path": "Design §9", + "headline": "Commit the deck answers files to git?", + "options": [ + { + "id": "a", + "label": "Commit them (recommended)", + "summary": "They hold your words verbatim and are the only record of decisions; ignored for three months, they lived on one disk. Already done on this branch — 27 files." + }, + { + "id": "b", + "label": "Copy them beside each contract at sign-off instead", + "summary": "Keeps a feature folder self-contained, but the rounds' history stays untracked and a clean checkout has none of it." + } + ] + } + ] +} diff --git a/docs/active/plans/2026-09-01-feature-flow-plan.md b/docs/active/plans/2026-09-01-feature-flow-plan.md new file mode 100644 index 00000000..6b09f587 --- /dev/null +++ b/docs/active/plans/2026-09-01-feature-flow-plan.md @@ -0,0 +1,1770 @@ +--- +status: draft +created: 2026-09-01 +type: plan +spec: docs/active/specs/2026-09-01-feature-flow-design.md +measured_at: + youcoded-dev: 5dacdf7 (origin/master, merged into docs/feature-flow-plan as 6d1c11b) +reviewed: 2026-09-01 — a second session verified every line anchor and interface against the code; the findings are folded in (see "Review changes" at the end) +--- + +# Feature Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the review deck carry the whole feature flow — questions before drawing, a contract at sign-off, an acceptance deck at the end — with a script that checks the contract holds together and a close-out section that reports it. + +**Architecture:** Everything lands in the existing deck tool (`scripts/ui-review/review-cards.py` + `scripts/ui-review/deck/`). One new step shape — *words-only* (a step with no picture; `"words": true`) — of which the *contract* step (a words step that carries `rows`) is a variant, not a fourth dispatch branch; plus a `contract.py` module for `contract-check` and `acceptance`. Answers files start being committed. Docs, one rule, and the `ui-mockup` skill are updated last so they describe what exists. + +**Tech Stack:** Python 3 (stdlib only, `unittest`), vanilla JS/CSS in the deck page, bash for `close-out.sh`, Node `--test` + headless Chrome for the one render test. + +## Global Constraints + +- Deck writing rules apply to every new text field: `HEADLINE_MAX = 25` words, `BANNED` words (`token`, `primitive`, `selector`, `ipc`, `prop`, `props`, `reducer`, `handler`, `component`, `tailwind`, `css class`, `react`, `dom`, `z-index`) in any user-facing field. +- New Python tests must be **picture-free** (no `magick`, no `ffmpeg`, no Chrome) so CI runs them — the pattern is `tests/test_live.py`. Register each new module in `.github/workflows/workspace-ci.yml` (line 107), `scripts/ui-review/README.md` (the `` block, ~line 262) and `docs/MAP.md` (the UI review rig row, line 19). +- Every non-trivial edit gets a WHY comment (Destin reads code through comments). +- Python tests run from the tests directory: `cd scripts/ui-review/tests && python3 -m unittest `. Never `-t .`. +- The spec's `runs`/`images` rule: a deck with no picture steps at all names neither (today `all_live`); this plan widens that to "no picture steps" without changing what a picture deck requires. +- Do not touch `youcoded/` — this plan is workspace-only. Commit with explicit paths (never `git add -A`). +- Branch: if youcoded-dev PR #10 (this plan) is still open, continue on `docs/feature-flow-plan` in `worktrees/feature-flow` (origin/master is already merged in); if it has merged, `git worktree add worktrees/feature-flow -b feat/feature-flow origin/master` and work there. +- **Line numbers in this plan are approximate; the quoted text beside each one is the anchor.** Find the text, never count lines. +- **Every file the flow writes sits beside the contract and shares its stem.** `.contract.json` → `.contract.answers.json` (the sign-off), `.contract.verdicts.json` (the grader's input), `.contract.acceptance.json` (the acceptance deck), `.contract.acceptance.answers.json`. No other names. +- **A guard named by a `mechanical` row may live on the feature branch.** `contract-check` looks for it on disk under the workspace root AND on the contract's `branch` (`git cat-file -e`), because the workspace root from a worktree is the main checkout, where a test the branch adds does not exist until merge. An uncommitted guard does not count. + +--- + +### Task 0: Track answers files under `docs/` + +**Files:** +- Modify: `.gitignore` (the two lines `*.answers.json` / `*.answers.*.json`, ~line 112, directly above `*.serve.json`) +- Add to git: every `docs/**/*.answers.json` and `docs/**/*.answers.*.json` on disk in the main checkout + +**Interfaces:** +- Produces: committed answers files that Task 4's `contract-check` and Task 6's dry run read. + +- [ ] **Step 1: Stop ignoring answers files** + +Delete the two lines `*.answers.json` and `*.answers.*.json` from `.gitignore` and put this comment where they were (`scratch/` is already ignored wholesale on its own line near the top, so throwaway decks need no pattern of their own): + +``` +# Deck answers (*.answers.json) are Destin's decisions and are COMMITTED (feature-flow design §2). +# Throwaway decks live in scratch/, which is ignored above. `*.serve.json` is a runtime lock. +``` + +- [ ] **Step 2: Verify the rule from the worktree** + +Run: +```bash +cd /home/destin/youcoded-dev/worktrees/feature-flow +touch docs/active/design/probe.answers.json +git check-ignore -v docs/active/design/probe.answers.json; echo "docs rc=$?" +mkdir -p scratch && touch scratch/probe.answers.json && git check-ignore -v scratch/probe.answers.json; echo "scratch rc=$?" +rm -f docs/active/design/probe.answers.json scratch/probe.answers.json +``` +Expected: `docs rc=1` (not ignored), `scratch rc=0` with the `.gitignore:…:scratch/` line printed. + +- [ ] **Step 3: Copy the existing answers files into the worktree and stage them** + +The files live only in the main checkout (they were never tracked). Copy, then stage by explicit path: + +```bash +cd /home/destin/youcoded-dev +find docs -name '*.answers.json' -o -name '*.answers.*.json' | while read -r f; do + mkdir -p "worktrees/feature-flow/$(dirname "$f")" && cp "$f" "worktrees/feature-flow/$f" +done +cd worktrees/feature-flow +git add .gitignore +find docs \( -name '*.answers.json' -o -name '*.answers.*.json' \) -print0 | xargs -0 git add -- +git status --short | head -40 +``` +Expected: 27 answers files staged (the count on 2026-09-01; `find` above prints the real one) plus `.gitignore`. + +- [ ] **Step 4: Commit** + +```bash +git commit -m "chore(deck): commit answers files under docs/ — they are Destin's decisions, not runtime output + +Every *.answers.json was gitignored since deck v2 (d81214a). The contract in +docs/active/specs/2026-09-01-feature-flow-design.md resolves its rows to these +files, so they need history and a clean-checkout life. scratch/ was already +ignored on its own line, so throwaway decks need no pattern. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" +``` + +--- + +### Task 1: Words-only steps (no picture), one-option decide, per-step button labels + +**Files:** +- Modify: `scripts/ui-review/deck/spec.py` (`load_spec` ~line 53; `validate` dispatch ~line 175; `_validate_decide` ~lines 218–258; new `is_words`, `is_contract`, `no_pictures`, `_validate_words`, `_validate_options`) +- Modify: `scripts/ui-review/deck/crops.py:40-52` (skip words steps) +- Modify: `scripts/ui-review/deck/build.py` (`_decide_step` ~line 63; `deck_data` ~line 122; `build_page` existence loop ~line 152) +- Modify: `scripts/ui-review/deck/page.js` (`YES`/`NO` line 104; `render()` lines 148–210; `layout()` line 226; `renderAnswers` line 124) +- Modify: `scripts/ui-review/deck/page.css` (after line 72) +- Create: `scripts/ui-review/tests/test_words.py` +- Modify: `scripts/ui-review/tests/fixture.py` (append `words_spec`) +- Modify: `scripts/ui-review/tests/deck-render.test.mjs` (second test) +- Modify: `.github/workflows/workspace-ci.yml:107`, `scripts/ui-review/README.md` (~262), `docs/MAP.md:19` + +**Interfaces:** +- Produces: `is_words(step) -> bool`, `no_pictures(spec) -> bool`, `_validate_options(st, sid, errors, warnings, minimum)` in `deck/spec.py`; deck data for a words step: `{'id', 'kind': 'decide'|None, 'words': True, 'surface', 'path', 'headline', 'changed', 'measured', 'notice', 'risk', 'yes', 'no', 'options'?}`; `fixture.words_spec(tmp, **over) -> path`. +- Consumed by: Task 3 (contract steps are laid out as words steps), Task 4 (acceptance deck emits words steps). + +- [ ] **Step 1: Add the words fixture** + +Append to `scripts/ui-review/tests/fixture.py`: + +```python +# ── words-only decks ──────────────────────────────────────────────────────────────────── +def words_spec(tmp, **over): + """A QUESTIONS deck: no pictures anywhere. One question with a single option (plus the + page's own Other), one with three, and one statement to approve with relabelled buttons. + Picture-free on purpose, like live_spec — this is CI coverage.""" + deck = os.path.join(tmp, 'deck') + os.makedirs(deck, exist_ok=True) + spec = { + 'title': 'Questions fixture', 'key': 'questions-fixture', 'out': 'questions.html', + 'themes': ['midnight', 'light'], + 'steps': [ + {'id': 'Q-1', 'words': True, 'surface': 'Games', 'path': 'Questions', + 'headline': 'Where does the invite live?', + 'options': [{'id': 'a', 'label': 'In the friends list (recommended)', 'summary': 'One place for everything about a friend.'}]}, + {'id': 'Q-2', 'words': True, 'surface': 'Games', 'path': 'Questions', + 'headline': 'How many boards on screen at once?', + 'options': [{'id': 'a', 'label': 'One', 'summary': 'Simplest.'}, + {'id': 'b', 'label': 'Two', 'summary': 'Mine and theirs.'}, + {'id': 'c', 'label': 'As many as fit', 'summary': 'Costs a layout rule.'}]}, + {'id': 'Q-3', 'words': True, 'surface': 'Games', 'path': 'Questions', + 'headline': 'A game you leave keeps running for the other player.', + 'changed': 'Stated, not asked: the alternative would surprise the friend who stayed.', + 'notice': 'Nothing yet — this becomes a row of the contract.', + 'yes': 'Holds', 'no': 'Fails'}, + ], + } + spec.update(over) + p = os.path.join(deck, 'questions.json') + with open(p, 'w') as f: + json.dump(spec, f, indent=1) + return p +``` + +- [ ] **Step 2: Write the failing tests** + +Create `scripts/ui-review/tests/test_words.py`: + +```python +"""Words-only steps: a question or a statement with NO picture. Validation, the data the page +gets, and the runs/images rule. Picture-free like test_live.py — this is what CI runs. +Plan: docs/active/plans/2026-09-01-feature-flow-plan.md Task 1.""" +import json +import os +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(HERE)) +sys.path.insert(0, HERE) +from fixture import words_spec # noqa: E402 +from deck.build import build_page, deck_data # noqa: E402 +from deck.crops import crop_images # noqa: E402 +from deck.spec import SpecError, is_words, load_spec, no_pictures, validate # noqa: E402 + + +def spec_with(tmp, mutate, **over): + p = words_spec(tmp, **over) + with open(p) as f: + raw = json.load(f) + mutate(raw) + with open(p, 'w') as f: + json.dump(raw, f) + return load_spec(p) + + +def errs(spec): + return validate(spec)[0] + + +class WordsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_words_deck_needs_no_images_or_runs(self): + s = load_spec(words_spec(self.tmp)) + self.assertTrue(no_pictures(s)) + self.assertNotIn('images', s) + self.assertEqual(list(s['runs']), ['today']) + self.assertEqual(errs(s), []) + + def test_one_option_is_enough_without_a_picture(self): + s = load_spec(words_spec(self.tmp)) + self.assertEqual([e for e in errs(s) if 'Q-1' in e], []) + + def test_a_picture_decide_still_needs_two_options(self): + # The two-option floor stays for picture decks: one option plus Other is a yes/no in disguise. + s = spec_with(self.tmp, lambda r: r['steps'].append( + {'id': 'D-1', 'surface': 'Games', 'path': 'Board', 'crop': 'bubble', 'highlight': {'text': 'Send'}, + 'headline': 'Bigger?', 'options': [{'id': 'a', 'label': 'Yes', 'summary': 'x'}]}), + images='images/questions', runs={'today': '/nowhere'}) + self.assertTrue(any('D-1: a decide step needs at least 2 options' in e for e in errs(s))) + + def test_words_step_refuses_a_picture(self): + s = spec_with(self.tmp, lambda r: r['steps'][0].update({'crop': 'bubble'})) + self.assertTrue(any('Q-1: a words step has no crop' in e for e in errs(s))) + + def test_words_statement_needs_its_body(self): + s = spec_with(self.tmp, lambda r: r['steps'][2].pop('notice')) + self.assertTrue(any('Q-3: missing notice' in e for e in errs(s))) + + def test_words_step_obeys_the_writing_rules(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['options'][0].update({'summary': 'Uses a new reducer'})) + self.assertTrue(any('banned word "reducer"' in e for e in errs(s))) + + def test_deck_data_marks_words_and_carries_labels(self): + s = load_spec(words_spec(self.tmp)) + d = deck_data(s, {}) + q1, q3 = d['steps'][0], d['steps'][2] + self.assertTrue(q1['words'] and q3['words']) + self.assertEqual(q1['kind'], 'decide'); self.assertEqual(len(q1['options']), 1) + self.assertNotIn('images', q1); self.assertNotIn('boxes', q1) + self.assertEqual((q3['yes'], q3['no']), ('Holds', 'Fails')) + self.assertNotIn('kind', q3) + + def test_crop_and_build_skip_words_steps(self): + s = load_spec(words_spec(self.tmp)) + r = crop_images(s, log=lambda m: None) + self.assertEqual((r['count'], r['missing']), (0, [])) + page, warnings = build_page(s, r['boxes']) + self.assertIn('"words": true', page) + self.assertEqual(warnings, []) + + def test_is_words_is_the_flag_not_a_guess(self): + # A step that merely FORGOT its crop is still an error, not a silent words step. + self.assertFalse(is_words({'id': 'x', 'headline': 'h'})) + self.assertTrue(is_words({'id': 'x', 'words': True})) + # A contract step (rows) is a words step too — even with no rows yet, so the empty + # contract gets the contract error in Task 3, not "missing crop". + self.assertTrue(is_words({'id': 'x', 'rows': []})) + + +if __name__ == '__main__': + unittest.main() +``` + +- [ ] **Step 3: Run to see them fail** + +Run: `cd scripts/ui-review/tests && python3 -m unittest test_words -v` +Expected: `ImportError: cannot import name 'is_words'`. + +- [ ] **Step 4: spec.py — the flag, the deck rule, the validator** + +In `scripts/ui-review/deck/spec.py`, after `is_clip` (~line 114) add: + +```python +def is_contract(step): + """A CONTRACT step is the rows that define done (feature-flow design §3), rendered as a + table and answered yes/no/other as ONE step. It is a WORDS step (is_words is true for it) + that carries `rows`; keyed on the key's PRESENCE so an empty `rows: []` still reaches the + contract validator ("a contract with no rows defines nothing") instead of the picture + one ("missing crop"). Validation and the page come in Task 3 of the plan.""" + return 'rows' in step + + +def is_words(step): + """A WORDS-ONLY step has no picture at all — `"words": true`, an explicit flag rather than + "no crop", so a step that merely forgot its crop is still an error and never renders + silently pictureless. With `options` it is a decide (pick one of the written options, or + Other); with `rows` a contract; otherwise a statement to approve (`changed` + `notice` + are its body). Users: the QUESTIONS deck answered before anything is drawn, the contract, + and the acceptance deck's human rows (feature-flow design §3, §5, §7).""" + return step.get('words') is True or is_contract(step) + + +def no_pictures(spec): + """A deck with no picture steps at all — every step is live or words-only (a contract is + words-only). It names no `images` folder and no `runs`; every code path that reaches for + either bails out first (load_spec, crops.py, build.py, review-cards.py). Widens + live.all_live.""" + return bool(spec['steps']) and all(is_live(st) or is_words(st) for st in spec['steps']) +``` + +(`is_live` is already imported at the top of `spec.py`. After the next change `all_live` is no longer used in `spec.py` — drop it from that import.) + +In `load_spec`, replace `if all_live(spec):` with `if no_pictures(spec):` and update the comment's first line to `# A deck whose every step is LIVE, WORDS-ONLY or a CONTRACT has no`. + +In `validate`, after the `is_live` block and before `if is_choice(st):`, add: + +```python + if is_words(st): + _validate_words(spec, st, sid, errors, warnings) + continue +``` + +Extract the option loop out of `_validate_decide`. Replace the block from `opts = st['options']` through the `measured has no number` warning (~lines 234–253; the `themes` and `risk` checks after it stay) with: + +```python + _validate_options(st, sid, errors, warnings, minimum=2) +``` + +and add, after `_validate_decide`: + +```python +def _validate_options(st, sid, errors, warnings, minimum): + """The written options of a decide step. `minimum` is 2 for a picture decide (one option + plus Other is a yes/no step in disguise) and 1 for a words-only question, where the + recommended answer alone plus Other is exactly the shape Destin asked for (2026-09-01).""" + opts = st['options'] + if not isinstance(opts, list) or len(opts) < minimum: + errors.append(f'{sid}: a decide step needs at least {minimum} option{"s" if minimum > 1 else ""}') + return + if len(opts) > 3: + warnings.append(f'{sid}: {len(opts)} options — more than three usually means two questions') + seen = set() + for i, o in enumerate(opts): + oid = o.get('id') or f'option {i + 1}' + if not o.get('id'): + errors.append(f'{sid}: {oid} has no id') + elif o['id'] in seen: + errors.append(f'{sid}: duplicate option id "{o["id"]}"') + seen.add(o.get('id')) + for k in ('label', 'summary'): + if not o.get(k): + errors.append(f'{sid}/{oid}: missing {k}') + for k in OPTION_TEXT_FIELDS: + for w in banned_in(o.get(k)): + errors.append(f'{sid}/{oid}: {k} uses banned word "{w}"') + if o.get('measured') and not re.search(r'\d', o['measured']): + warnings.append(f'{sid}/{oid}: measured has no number in it') + + +def _validate_words(spec, st, sid, errors, warnings): + """No picture, so every picture field is refused rather than required — the same stance + as _validate_live. The question shape is the existing one: `options` → pick one. A + contract (`rows`) is validated by _validate_rows (Task 3), which this dispatches to.""" + for k in ('surface', 'path', 'headline'): + if not st.get(k): + errors.append(f'{sid}: missing {k}') + for k in ('crop', 'clip', 'highlight', 'variants', 'live'): + if st.get(k): + errors.append(f'{sid}: a words step has no {k} — there is no picture') + _headline_and_words(st, sid, errors) + if is_contract(st): + _validate_rows(spec, st, sid, errors) # Task 3 adds it; until then a contract step is not valid + elif st.get('options'): + _validate_options(st, sid, errors, warnings, minimum=1) + else: + for k in ('changed', 'notice'): + if not st.get(k): + errors.append(f'{sid}: missing {k} (a words step with no options is a statement to approve; these are its body)') + for k in ('yes', 'no'): + if st.get(k) and word_count(st[k]) > 4: + errors.append(f'{sid}: {k} label is {word_count(st[k])} words — a button, keep it under 5') + th = st.get('themes') + if th is not None and (not isinstance(th, list) or not th or not all(isinstance(t, str) for t in th)): + errors.append(f'{sid}: themes must be a non-empty list of theme names') + if word_count(st.get('risk')) > RISK_WARN: + warnings.append(f'{sid}: risk is {word_count(st["risk"])} words — keep it to one sentence') +``` + +Note the `_validate_decide` docstring's claim "at least 2 options" now lives in `_validate_options`; the existing `test_spec` decide tests keep passing because the message text for `minimum=2` is unchanged. + +- [ ] **Step 5: crops.py — skip words steps** + +In `scripts/ui-review/deck/crops.py`, change the import line to +`from .spec import AUTO_WARN_FRACTION, is_choice, is_words, no_pictures, run_names, step_themes, is_clip`, replace `if all_live(spec):` (~line 41) with `if no_pictures(spec):` and update the comment above it (LIVE → live or words-only), drop `all_live` from the `.live` import (nothing else in crops.py uses it), and after the `if is_live(st): continue` in the loop add: + +```python + if is_words(st): + continue # words only (a question, a statement, a contract) — nothing to cut, no `crop` to look up +``` + +Add a Task-1 step to `_validate_words`'s "until Task 3" gap: in `spec.py` define a placeholder so Task 1's suite runs green on its own: + +```python +def _validate_rows(spec, st, sid, errors): + errors.append(f'{sid}: contract steps are not supported yet (plan Task 3)') +``` + +(Task 3 replaces the body. `live.all_live` stays for `test_live.py`, which pins it.) + +- [ ] **Step 6: build.py — the words step data and the existence loop** + +In `scripts/ui-review/deck/build.py` import `is_words` from `.spec`, extract the option mapping and add the words builder: + +```python +def _option(o): + return {'id': o['id'], 'label': o['label'], 'summary': o['summary'], + 'measured': o.get('measured', ''), 'cost': o.get('cost', '')} + + +def _words_step(spec, st): + """No picture: the cards take the whole row (page.js lays a `words` step out without a + stage). With `options` it answers like a decide; without, like an approve, and `yes`/`no` + relabel the buttons — "Holds / Fails" on an acceptance row, not "Yes, build it".""" + d = {'id': st['id'], 'words': True, 'surface': st['surface'], 'path': st['path'], 'headline': st['headline'], + 'changed': st.get('changed', ''), 'measured': st.get('measured', ''), + 'notice': st.get('notice', ''), 'risk': st.get('risk', ''), + 'yes': st.get('yes', ''), 'no': st.get('no', ''), + **({'themes': list(st['themes'])} if st.get('themes') else {})} + if st.get('options'): + d['kind'] = 'decide' + d['options'] = [_option(o) for o in st['options']] + return d +``` + +In `_decide_step` replace the inline options list with `[_option(o) for o in st['options']]`. In `deck_data` add the dispatch **before** `is_choice`: + +```python + steps = [_live_step(spec, st) if is_live(st) + else _words_step(spec, st) if is_words(st) + else _choice_step(spec, st, boxes, runs[-1]) if is_choice(st) + ... +``` + +In `build_page`'s loop, after `if is_live(st): continue`, add: + +```python + if is_words(st): + continue # nothing on disk to check — a question, a statement or a contract has no picture +``` + +(`frames()` in page.js already falls through to `runs.map(...)` for a step with no `kind`, and `render()` ends with `layout()`, so the module-load `curFrames = frames(DECK.steps[0])` and the words layout both work with the edits in Step 7 — verified 2026-09-01.) + +- [ ] **Step 7: page.js — render and lay out a words step** + +In `scripts/ui-review/deck/page.js`: + +1. Line 104: keep `YES`/`NO` as the deck defaults and add per-step labels right below: +```js + // A words step may relabel the buttons ("Holds / Fails" on an acceptance row): the deck's + // build/keep wording is about a picture, and a statement has none. + const yesLabel = st => st.yes || YES, noLabel = st => st.no || NO; +``` + and in `renderAnswers` use `${yesLabel(st)}` / `${noLabel(st)}` in place of `${YES}` / `${NO}`. + +2. In `render()`, replace `curFrames = frames(st);` and the `inner.innerHTML = …` line with: +```js + // A words step has no frames: the stage is hidden by layout() and the cards fill the row. + curFrames = st.words ? [] : frames(st); + inner.innerHTML = curFrames.map(f => `
${f.caption}
${media(st, f)}
`).join(''); +``` + Change `$('#zoom').hidden = st.kind === 'live';` to `$('#zoom').hidden = st.kind === 'live' || !!st.words;`. + Replace `const last = curFrames[curFrames.length - 1].key;` with `const last = curFrames.length ? curFrames[curFrames.length - 1].key : null;` and make the thumbs expression start with `st.words ? '' :` (a words step has no picture of any theme; the theme pills would be empty). + +3. At the top of `layout()`, before the live line: +```js + if (DECK.steps[cur].words) { // no picture to size: one column of cards, answer bar under it + $('#content').className = 'content words'; $('#step').classList.remove('compact-step'); + document.body.dataset.layout = 'words'; window.__deckReady = true; return; + } +``` + +- [ ] **Step 8: page.css — the words layout** + +After line 72 (`.content.compact{…}`) add: + +```css +/* WORDS: no stage at all — the question and its option cards take the row (feature-flow §5) */ +.content.words{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"decide"} .content.words .stage{display:none} +.words .cards{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))} .words .card.option{min-height:96px} +``` + +- [ ] **Step 9: Run the Python tests** + +Run: `cd scripts/ui-review/tests && python3 -m unittest test_words test_spec test_live -v` +Expected: all pass (`test_words` 9 tests). + +- [ ] **Step 10: Add the render test** + +Append to `scripts/ui-review/tests/deck-render.test.mjs` (reuse `cdp`, `freePort`, `sleep`, `RC`, `HERE` from the file): + +```js +test('a words-only deck renders with no stage and records a pick', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'deck-words-')); + const fx = spawnSync('python3', ['-c', `import sys; sys.path.insert(0, ${JSON.stringify(HERE)}); from fixture import words_spec; print(words_spec(${JSON.stringify(tmp)}))`], { encoding: 'utf8' }); + const spec = fx.stdout.trim(); assert.ok(spec.endsWith('questions.json'), fx.stderr); + { const r = spawnSync('python3', [RC, 'build', spec], { encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); } + const port = await freePort(); + const srv = spawn('python3', [RC, 'serve', spec, '--no-open', '--no-build', '--port', String(port), '--timeout', '2'], { stdio: ['ignore', 'pipe', 'pipe'] }); + try { + await sleep(800); + const c = await cdp(await freePort(), 1400, 900); + try { + await c.send('Page.navigate', { url: `http://127.0.0.1:${port}/questions.html` }); + for (let i = 0; i < 40 && !(await c.evaluate('window.__deckReady === true')); i++) await sleep(100); + assert.equal(await c.evaluate('document.body.dataset.layout'), 'words'); + assert.equal(await c.evaluate("getComputedStyle(document.querySelector('#stage')).display"), 'none'); + assert.equal(await c.evaluate("document.querySelectorAll('.card.option').length"), 1); // Q-1: one option + assert.equal(await c.evaluate("[...document.querySelectorAll('.ans')].map(b => b.dataset.v).join(',')"), 'other'); + await c.evaluate("document.querySelector('.card.option').click()"); + await c.evaluate("document.querySelector('#save').click()"); // → Q-2 + await c.evaluate("document.querySelector('#next').click()"); // → Q-3 + await sleep(200); + assert.equal(await c.evaluate("[...document.querySelectorAll('.ans')].map(b => b.textContent).join(',')"), 'Holds,Fails,Other'); + await sleep(400); + const answers = JSON.parse(readFileSync(spec.replace(/\.json$/, '.answers.json'), 'utf8')); + assert.deepEqual([answers.answers['Q-1'].v, answers.answers['Q-1'].pick], ['pick', 'a']); + assert.deepEqual(c.errors, []); + } finally { c.close(); } + } finally { srv.kill(); } +}); +``` + +Run: `node --test scripts/ui-review/tests/deck-render.test.mjs` +Expected: 2 tests pass. (Local only — needs Chrome.) + +- [ ] **Step 11: Register the suite** + +- `.github/workflows/workspace-ci.yml:107`: `run: python3 -m unittest -v test_spec test_tokens test_live test_words`, and add `test_words` to the comment above it that lists the picture-free suites. +- `scripts/ui-review/README.md` `` block: `python3 -m unittest test_spec test_tokens test_live test_words`; the "Everything" count line changes — run the discover command and write the real number. +- `docs/MAP.md:19`: same command change in the guard-tests column. +- `scripts/ui-review/review-cards.py` docstring: after the five kinds sentence add: `A step may instead be WORDS-ONLY ("words": true — a question with 1–3 written options, or a statement to approve; no picture, no images folder needed): that is the questions deck asked before anything is drawn.` + +- [ ] **Step 12: Commit** + +```bash +git add scripts/ui-review/deck/spec.py scripts/ui-review/deck/crops.py scripts/ui-review/deck/build.py scripts/ui-review/deck/page.js scripts/ui-review/deck/page.css scripts/ui-review/review-cards.py scripts/ui-review/tests/test_words.py scripts/ui-review/tests/fixture.py scripts/ui-review/tests/deck-render.test.mjs .github/workflows/workspace-ci.yml scripts/ui-review/README.md docs/MAP.md +git commit -m "feat(deck): words-only steps — a question deck with no picture, one option is enough + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" +``` + +--- + +### Task 2: Note tags — fix now / fix later / just noting + +**Files:** +- Modify: `scripts/ui-review/deck/page.html.tmpl:19` (after the note input) +- Modify: `scripts/ui-review/deck/page.js` (`paintState` line 212; note input handler line 272) +- Modify: `scripts/ui-review/deck/page.css:165` (after `.note`) +- Modify: `scripts/ui-review/deck/serve.py:40-57` (`summary`) +- Test: `scripts/ui-review/tests/test_words.py` (summary case), `deck-render.test.mjs` (tag click) + +**Interfaces:** +- Produces: answers entries gain `note_kind: 'now' | 'later' | 'noting'` whenever `note` is non-empty; `summary()` prints ` [fix now]` etc. after the quoted note. +- Consumed by: Task 6's contract agent prompt (routing rule for notes). + +- [ ] **Step 1: Failing summary test** + +Add to `WordsTests` in `test_words.py`: + +```python + def test_summary_names_the_note_tag(self): + from deck.serve import summary + s = load_spec(words_spec(self.tmp)) + state = {'submitted': '2026-09-01T10:00:00Z', 'answers': { + 'Q-1': {'v': 'pick', 'pick': 'a', 'note': 'but smaller', 'note_kind': 'now'}, + 'Q-3': {'v': 'yes', 'note': 'fine', 'note_kind': 'noting'}}} + lines = summary(s, state).split('\n') + self.assertEqual(lines[1], 'Q-1 pick a — "but smaller" [fix now]') + self.assertEqual(lines[3], 'Q-3 yes — "fine" [just noting]') +``` + +Run: `cd scripts/ui-review/tests && python3 -m unittest test_words.WordsTests.test_summary_names_the_note_tag` +Expected: FAIL — `'Q-1 pick a — "but smaller"' != 'Q-1 pick a — "but smaller" [fix now]'`. + +- [ ] **Step 2: serve.py summary** + +In `summary()` replace the `lines.append(...)` line with: + +```python + # The tag says what the note IS — next-round work, a roadmap line, or a remark — so the + # contract agent routes it instead of guessing (feature-flow design §5). + tag = NOTE_KIND.get(a.get('note_kind'), '') + lines.append(f'{st["id"]} {what}' + (f' — "{note}"' + (f' [{tag}]' if tag else '') if note else '')) +``` + +and add near the top of `serve.py`: `NOTE_KIND = {'now': 'fix now', 'later': 'fix later', 'noting': 'just noting'}`. + +- [ ] **Step 3: The tag buttons** + +`page.html.tmpl` line 19 — after the `` add: + +```html + +``` + +`page.css` after `.note` (line 165): + +```css +/* Note tags: what the note IS. Shown only once a note has text; "Just noting" is preselected so nothing is inferred. */ +.tags{display:inline-flex;gap:6px} .tag{font:inherit;font-size:11px;padding:4px 9px;border:1px solid var(--edge);border-radius:999px;background:var(--well);color:var(--fg-dim);cursor:pointer} .tag.on{border-color:var(--mark);color:var(--fg);box-shadow:inset 0 0 0 1px var(--mark)} +.compact .controls .tags{grid-column:1/4} +``` + +`page.js`: + +1. In `paintState()` after the `note.placeholder` line: +```js + const hasNote = !!(a.note && a.note.trim()); + $('#tags').hidden = !hasNote; + $$('#tags .tag').forEach(b => b.classList.toggle('on', hasNote && b.dataset.kind === (a.note_kind || 'noting'))); +``` +2. Replace the note `input` handler (line 272) with: +```js + $('#note').addEventListener('input', e => { + const id = DECK.steps[cur].id; const a = { ...(state.answers[id] || {}), note: e.target.value }; + // A note that just gained text is "just noting" until he says otherwise — a visible default, + // not an inference: it is on screen, selected, and one click away from the other two. + if (a.note.trim() && !a.note_kind) a.note_kind = 'noting'; + if (!a.note.trim()) delete a.note_kind; + state.answers[id] = a; paintState(); clearTimeout(noteTimer); noteTimer = setTimeout(save, 300); + }); + $('#tags').addEventListener('click', e => { + const b = e.target.closest('.tag'); if (!b || state.submitted) return; + const id = DECK.steps[cur].id; state.answers[id] = { ...(state.answers[id] || {}), note_kind: b.dataset.kind }; paintState(); save(); + }); +``` +3. In `lockSubmitted()` add `.tag` to the disabled selector: `$$('.ans,#save,#note,.tag')`. +4. In the page's own `summary()` (line 280) mirror the tag: after `a.note.trim() + '"'` append `+ (a.note_kind ? ' [' + {now:'fix now',later:'fix later',noting:'just noting'}[a.note_kind] + ']' : '')`. + +- [ ] **Step 4: Render test — the tag lands in the file** + +In the words render test from Task 1, after the `#save` click line insert: + +```js + await c.evaluate("document.querySelector('#prev').click()"); // back to Q-1 + await c.evaluate("const n = document.querySelector('#note'); n.value = 'smaller'; n.dispatchEvent(new Event('input'))"); + assert.equal(await c.evaluate("document.querySelector('#tags').hidden"), false); + await c.evaluate("document.querySelector('.tag[data-kind=later]').click()"); + await c.evaluate("document.querySelector('#save').click()"); // → Q-2 again +``` +and extend the final assertion: `assert.equal(answers.answers['Q-1'].note_kind, 'later');`. + +- [ ] **Step 5: Run and commit** + +Run: `cd scripts/ui-review/tests && python3 -m unittest test_words test_serve && cd ../../.. && node --test scripts/ui-review/tests/deck-render.test.mjs` +Expected: all pass. + +```bash +git add scripts/ui-review/deck/page.html.tmpl scripts/ui-review/deck/page.js scripts/ui-review/deck/page.css scripts/ui-review/deck/serve.py scripts/ui-review/tests/test_words.py scripts/ui-review/tests/deck-render.test.mjs +git commit -m "feat(deck): a note carries a tag — fix now / fix later / just noting — so nothing about it is inferred + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" +``` + +--- + +### Task 3: The contract step + +**Files:** +- Modify: `scripts/ui-review/deck/spec.py` (`_validate_rows` body replaces Task 1's placeholder; constants) +- Modify: `scripts/ui-review/deck/build.py` (`_words_step` gains its rows branch) +- Modify: `scripts/ui-review/deck/page.js` (`render()` cards branch) +- Modify: `scripts/ui-review/deck/page.css` +- Modify: `scripts/ui-review/tests/fixture.py` (`contract_spec`) +- Create: `scripts/ui-review/tests/test_contract.py` +- Create: `scripts/ui-review/templates/contract.json` (the template Task 6's agent copies) + +**Interfaces:** +- Produces: `CHECKED_BY = ('mechanical', 'deck', 'live-app', 'human')`; deck data `{'id', 'kind': 'contract', 'words': True, 'surface', 'path', 'headline', 'changed', 'measured', 'notice', 'risk', 'rows': [{'id','statement','checkedBy','guard','threshold','source','note','verdict','evidence'}], 'yes', 'no'}` (the words-step keys plus `kind`, `rows`, and defaulted button labels); `fixture.contract_spec(tmp, **over) -> path` which also writes two source decks with **submitted** answers files beside it. `is_contract` and the no-pictures / crop / build skips already exist from Task 1 — a contract step is a words step, so nothing is dispatched anew here. +- Consumed by: Task 4 (`contract.py` reads rows and `sources`), Task 5. + +- [ ] **Step 1: Fixture** + +Append to `fixture.py`: + +```python +# ── contract ──────────────────────────────────────────────────────────────────────────── +def contract_spec(tmp, **over): + """A contract deck plus the two source decks its rows point at, each with a SUBMITTED + answers file — so contract-check has something real to resolve. Picture-free.""" + deck = os.path.join(tmp, 'deck') + os.makedirs(deck, exist_ok=True) + # Source deck 1: a words question, answered. Source deck 2: a picture step, answered. + q = {'title': 'Q', 'key': 'arcade-questions', 'out': 'q.html', 'themes': ['midnight'], + 'steps': [{'id': 'Q-1', 'words': True, 'surface': 'Games', 'path': 'Questions', 'headline': 'Where does the invite live?', + 'options': [{'id': 'a', 'label': 'Friends list', 'summary': 'One place.'}]}]} + r1 = {'title': 'R1', 'key': 'arcade-r1', 'out': 'r1.html', 'images': 'images/r1', 'runs': {'today': '/nowhere'}, + 'crops': {'c': ['main', 'home', '10x10+0+0']}, + 'steps': [{'id': 'S-1', 'surface': 'Board', 'path': 'Games', 'crop': 'c', 'highlight': {'text': 'Send'}, + 'headline': 'Boards are told apart.', 'changed': 'A colour band.', 'notice': 'Two boards.'}, + {'id': 'S-2', 'surface': 'Board', 'path': 'Games', 'crop': 'c', 'highlight': {'text': 'Send'}, + 'headline': 'Skipped one.', 'changed': 'x', 'notice': 'y'}]} + for name, s in (('q', q), ('r1', r1)): + with open(os.path.join(deck, f'{name}.json'), 'w') as f: + json.dump(s, f, indent=1) + with open(os.path.join(deck, 'q.answers.json'), 'w') as f: + json.dump({'deck': 'arcade-questions', 'submitted': '2026-09-01T09:00:00Z', + 'answers': {'Q-1': {'v': 'pick', 'pick': 'a', 'seconds': 12}}}, f) + with open(os.path.join(deck, 'r1.answers.json'), 'w') as f: + json.dump({'deck': 'arcade-r1', 'submitted': '2026-09-01T09:30:00Z', + 'answers': {'S-1': {'v': 'yes', 'note': 'band could be thinner', 'note_kind': 'later', 'seconds': 20}, + 'S-2': {'v': 'skip', 'seconds': 1}}}, f) + spec = { + 'title': 'Arcade — contract', 'key': 'arcade-contract', 'out': 'contract.html', 'themes': ['midnight'], + 'branch': 'feat/arcade-fixture', + 'sources': {'arcade-questions': 'q.json', 'arcade-r1': 'r1.json'}, + 'steps': [{'id': 'C', 'surface': 'Games arcade', 'path': 'Contract', 'headline': 'This is what done means.', + 'rows': [ + {'id': 'R1', 'statement': 'The invite lives in the friends list.', 'checkedBy': 'deck', + 'threshold': 'pass/fail', 'source': 'arcade-questions#Q-1'}, + {'id': 'R2', 'statement': "A second player's board is tellable from mine at a glance.", + 'checkedBy': 'human', 'threshold': 'pass/fail', 'source': 'arcade-r1#S-1', 'note': 'band could be thinner'}, + # The guard must exist under workspace_root() — which from a WORKTREE is the main + # checkout, so it has to be a file already on master, not one this branch adds. + {'id': 'R3', 'statement': 'The board fills the pane at every width.', 'checkedBy': 'mechanical', + 'guard': 'scripts/ui-review/tests/test_spec.py', 'threshold': 'the named test passes', + 'source': 'arcade-r1#S-1'}, + ]}], + } + spec.update(over) + # `.contract.json` — the `.contract` in the stem is what close-out.sh globs for. + p = os.path.join(deck, 'arcade.contract.json') + with open(p, 'w') as f: + json.dump(spec, f, indent=1) + return p +``` + +- [ ] **Step 2: Failing tests** + +Create `scripts/ui-review/tests/test_contract.py`: + +```python +"""The contract step (rows Destin signs off) and, from Task 4, contract-check + the acceptance +deck. Picture-free like test_live.py. Design: docs/active/specs/2026-09-01-feature-flow-design.md §3–§7.""" +import json +import os +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(HERE)) +sys.path.insert(0, HERE) +from fixture import contract_spec # noqa: E402 +from deck.build import build_page, deck_data # noqa: E402 +from deck.spec import is_contract, load_spec, no_pictures, validate # noqa: E402 + + +def spec_with(tmp, mutate, **over): + p = contract_spec(tmp, **over) + with open(p) as f: + raw = json.load(f) + mutate(raw) + with open(p, 'w') as f: + json.dump(raw, f) + return load_spec(p) + + +def errs(spec): + return validate(spec)[0] + + +class ContractStepTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_valid_contract_has_no_errors_and_no_pictures(self): + s = load_spec(contract_spec(self.tmp)) + self.assertTrue(is_contract(s['steps'][0])); self.assertTrue(no_pictures(s)) + self.assertEqual(errs(s), []) + + def test_row_fields(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][0].update({'checkedBy': 'vibes', 'source': 'nohash'})) + e = errs(s) + self.assertTrue(any('C/R1: checkedBy must be one of mechanical, deck, live-app, human' in x for x in e)) + self.assertTrue(any('C/R1: source must look like #' in x for x in e)) + + def test_mechanical_needs_a_guard(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][2].pop('guard')) + self.assertTrue(any('C/R3: a mechanical row needs a guard' in x for x in errs(s))) + + def test_source_key_must_be_in_sources(self): + s = spec_with(self.tmp, lambda r: r['sources'].pop('arcade-r1')) + self.assertTrue(any('C/R2: source deck "arcade-r1" is not in the spec\'s "sources"' in x for x in errs(s))) + + def test_verdict_needs_evidence(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][0].update({'verdict': 'pass'})) + self.assertTrue(any('C/R1: a verdict needs evidence' in x for x in errs(s))) + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][0].update({'verdict': 'maybe', 'evidence': 'x'})) + self.assertTrue(any('C/R1: verdict must be pass or fail' in x for x in errs(s))) + + def test_statement_obeys_writing_rules(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][0].update({'statement': 'The reducer stores it.'})) + self.assertTrue(any('C/R1: statement uses banned word "reducer"' in x for x in errs(s))) + + def test_duplicate_row_ids(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][1].update({'id': 'R1'})) + self.assertTrue(any('C: duplicate row id "R1"' in x for x in errs(s))) + + def test_empty_rows_is_the_contract_error_not_a_missing_crop(self): + s = spec_with(self.tmp, lambda r: r['steps'][0].update({'rows': []})) + e = errs(s) + self.assertTrue(any('C: a contract with no rows defines nothing' in x for x in e), e) + self.assertFalse(any('missing crop' in x for x in e), e) + + def test_contract_refuses_options(self): + s = spec_with(self.tmp, lambda r: r['steps'][0].update({'options': [{'id': 'a', 'label': 'x', 'summary': 'y'}]})) + self.assertTrue(any('C: a contract step has no options' in x for x in errs(s))) + + def test_deck_data_and_page(self): + s = load_spec(contract_spec(self.tmp)) + st = deck_data(s, {})['steps'][0] + self.assertEqual((st['kind'], st['words']), ('contract', True)) + self.assertEqual([r['id'] for r in st['rows']], ['R1', 'R2', 'R3']) + self.assertEqual((st['yes'], st['no']), ('Yes, that is done', 'No, something is missing')) + self.assertNotIn('options', st) + page, _ = build_page(s, {}) + self.assertIn('"kind": "contract"', page) + + +if __name__ == '__main__': + unittest.main() +``` + +Run: `cd scripts/ui-review/tests && python3 -m unittest test_contract -v` +Expected: `ImportError: cannot import name 'is_contract'`. + +- [ ] **Step 3: spec.py** + +`is_contract` already exists (Task 1) and `_validate_words` already dispatches a rows step to `_validate_rows`. Add near the other constants: + +```python +CHECKED_BY = ('mechanical', 'deck', 'live-app', 'human') +SOURCE_RE = re.compile(r'^[\w.-]+#[\w.-]+$') +``` + +Replace Task 1's placeholder `_validate_rows` with (the shared words checks — surface/path/headline, picture fields refused, headline length and banned words — already ran in `_validate_words`): + +```python +def _validate_rows(spec, st, sid, errors): + """The rows of a contract step (feature-flow design §3). Each row is one statement of what + done means, who checks it, and the answered deck step it came from.""" + if st.get('options'): + errors.append(f'{sid}: a contract step has no options — the rows are its body') + rows = st['rows'] + if not isinstance(rows, list): + errors.append(f'{sid}: rows must be a list') + return + sources = spec.get('sources') or {} + seen = set() + for i, r in enumerate(rows): + rid = r.get('id') or f'row {i + 1}' + if not r.get('id'): + errors.append(f'{sid}: {rid} has no id') + elif r['id'] in seen: + errors.append(f'{sid}: duplicate row id "{r["id"]}"') + seen.add(r.get('id')) + if not r.get('statement'): + errors.append(f'{sid}/{rid}: missing statement') + for k in ('statement', 'threshold', 'note'): + for w in banned_in(r.get(k)): + errors.append(f'{sid}/{rid}: {k} uses banned word "{w}"') + if r.get('checkedBy') not in CHECKED_BY: + errors.append(f'{sid}/{rid}: checkedBy must be one of {", ".join(CHECKED_BY)}') + if r.get('checkedBy') == 'mechanical' and not r.get('guard'): + errors.append(f'{sid}/{rid}: a mechanical row needs a guard (a workspace-relative test or script path)') + src = r.get('source') or '' + if not SOURCE_RE.match(src): + errors.append(f'{sid}/{rid}: source must look like #') + elif src.split('#')[0] not in sources: + errors.append(f'{sid}/{rid}: source deck "{src.split("#")[0]}" is not in the spec\'s "sources"') + if 'verdict' in r: + if r['verdict'] not in ('pass', 'fail'): + errors.append(f'{sid}/{rid}: verdict must be pass or fail') + if not r.get('evidence'): + errors.append(f'{sid}/{rid}: a verdict needs evidence (what was run or looked at)') + if not rows: + errors.append(f'{sid}: a contract with no rows defines nothing') +``` + +(The `themes` and `risk` checks at the end of `_validate_words` run for a contract step too — it returns to `_validate_words` after this.) + +- [ ] **Step 4: build.py** + +Add the constant and give `_words_step` its rows branch (import `is_contract` from `.spec`): + +```python +ROW_KEYS = ('id', 'statement', 'checkedBy', 'guard', 'threshold', 'source', 'note', 'verdict', 'evidence') +``` + +In `_words_step`, replace the `if st.get('options'):` tail with: + +```python + if is_contract(st): + # The rows, verbatim, and the two buttons a sign-off needs; page.js draws `rows` as a table. + d['kind'] = 'contract' + d['rows'] = [{k: r.get(k, '') for k in ROW_KEYS} for r in st['rows']] + d['yes'] = st.get('yes') or 'Yes, that is done' + d['no'] = st.get('no') or 'No, something is missing' + elif st.get('options'): + d['kind'] = 'decide' + d['options'] = [_option(o) for o in st['options']] + return d +``` + +Nothing changes in `deck_data`, `build_page` or `crops.py`: Task 1's `is_words` skips already cover a contract step. + +- [ ] **Step 5: page.js and CSS** + +In `render()`'s `$('#cards').innerHTML = …` chain, add a first branch: + +```js + const graded = st.kind === 'contract' && st.rows.some(r => r.verdict); + const rowsTable = () => `
${graded ? '' : ''}${st.rows.map(r => `${graded ? `` : ''}`).join('')}
#StatementChecked byThresholdFromVerdict
${esc(r.id)}${esc(r.statement)}${r.note ? `

“${esc(r.note)}”

` : ''}
${esc(r.checkedBy)}${r.guard ? `

${esc(r.guard)}

` : ''}
${esc(r.threshold || 'pass / fail')}${esc(r.source)}${esc(r.verdict || '—')}${r.evidence ? `

${esc(r.evidence)}

` : ''}
`; + $('#cards').innerHTML = st.kind === 'contract' + ? rowsTable() + + (st.notice ? `

${ICON.eye}You'll notice

${esc(st.notice)}

` : '') + + (st.risk ? `

${ICON.warn}Risk

${esc(st.risk)}

` : '') + : pickList(st) + ? …(existing chain unchanged)… +``` + +`page.css`, after the words rules from Task 1: + +```css +/* CONTRACT: the rows as a table; a graded row is tinted by its verdict */ +.card.contract{overflow:auto} .card.contract table{border-collapse:collapse;width:100%;font-size:12px} .card.contract th{text-align:left;font-weight:600;color:var(--fg-dim);padding:6px 8px;border-bottom:1px solid var(--edge)} .card.contract td{padding:6px 8px;border-bottom:1px solid var(--edge);vertical-align:top} +.card.contract .src{margin:2px 0 0;font-size:11px;color:var(--fg-muted)} .card.contract tr.pass td:last-child{color:var(--yes)} .card.contract tr.fail td:last-child{color:var(--no)} +.words .cards:has(.contract){grid-template-columns:1fr} +``` + +(`--yes` / `--no` are the existing answer-button colours, defined on `:root` at the top of `page.css` — verified 2026-09-01.) + +- [ ] **Step 6: Template** + +Create `scripts/ui-review/templates/contract.json` — the fixture's contract spec with placeholder text replaced by instructions in the values, e.g. `"statement": ""`, `"source": "#"`, `"branch": ""`, `"sources": {"": ""}`. Two example rows (one `human`, one `mechanical`). + +- [ ] **Step 7: Run, register, commit** + +Run: `cd scripts/ui-review/tests && python3 -m unittest test_contract test_words test_spec -v` +Expected: all pass (`test_contract` 10). + +Register `test_contract` in `workspace-ci.yml`, README and MAP alongside `test_words` (Task 1 Step 11 lists the three places). Add to the `review-cards.py` docstring: `A CONTRACT step ("rows") is the definition of done signed off as one step; see docs/active/specs/2026-09-01-feature-flow-design.md.` + +```bash +git add scripts/ui-review/deck/spec.py scripts/ui-review/deck/build.py scripts/ui-review/deck/page.js scripts/ui-review/deck/page.css scripts/ui-review/tests/fixture.py scripts/ui-review/tests/test_contract.py scripts/ui-review/templates/contract.json scripts/ui-review/review-cards.py .github/workflows/workspace-ci.yml scripts/ui-review/README.md docs/MAP.md +git commit -m "feat(deck): the contract step — the rows that define done, signed off as one step + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" +``` + +--- + +### Task 4: `contract-check` and `acceptance` + +**Files:** +- Create: `scripts/ui-review/deck/contract.py` +- Modify: `scripts/ui-review/review-cards.py` (subcommands) +- Test: `scripts/ui-review/tests/test_contract.py` (append) + +**Interfaces:** +- Produces: `check_contract(spec) -> list[str]` (empty = holds); `answers_for(spec_path) -> (spec_dict|None, answers_dict|None, why:str)`; `guard_exists(root, branch, guard) -> bool` (on disk under `root`, or committed on `branch` / `origin/` in the repo the path's first segment names); `signoff(spec) -> (ok: bool, line: str)` (the contract's OWN answers file is submitted and its contract step answered `yes` — the gate's third fact, design §4); `acceptance_status(spec) -> (ok: bool, line: str)` (`.acceptance.json` exists and its answers are submitted); `acceptance_spec(spec, verdicts) -> dict`. +- CLI `review-cards.py contract-check `: problems → one per line on stderr, exit 1. Otherwise exit 0 and print three lines, each prefixed `ok: ` or `todo: ` so `close-out.sh` (Task 5) maps them to its OK/TODO without parsing JSON: `ok: contract holds: N rows, every source answered and submitted, every guard found`, then the sign-off line, then the acceptance line. Signing and acceptance are reported, never required — the contract agent runs this before Destin has seen the deck. +- CLI `review-cards.py acceptance `: writes `.acceptance.json` beside it from `.verdicts.json` (i.e. `.contract.verdicts.json`), prints the path; exit 1 with reasons if a graded row lacks a verdict. +- Consumed by: Task 5 (`close-out.sh` calls `contract-check`), Task 6 (dry run), Task 7 (docs). + +- [ ] **Step 1: Failing tests** + +Append to `test_contract.py`: + +```python +class ContractCheckTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_fixture_contract_holds(self): + from deck.contract import check_contract + s = load_spec(contract_spec(self.tmp)) + self.assertEqual(check_contract(s), []) + + def test_unsubmitted_source_is_reported(self): + from deck.contract import check_contract + p = contract_spec(self.tmp) + ap = os.path.join(os.path.dirname(p), 'r1.answers.json') + a = json.load(open(ap)); a['submitted'] = None; json.dump(a, open(ap, 'w')) + problems = check_contract(load_spec(p)) + self.assertTrue(any('R2: r1.json answers were never submitted' in x for x in problems), problems) + + def test_rotated_answers_are_found(self): + # serve re-run after a submit moves the file to .answers..json (serve.rotate_submitted); + # the check reads the newest SUBMITTED file, whichever name it carries. + from deck.contract import check_contract + p = contract_spec(self.tmp); d = os.path.dirname(p) + os.replace(os.path.join(d, 'r1.answers.json'), os.path.join(d, 'r1.answers.202609010930.json')) + json.dump({'deck': 'arcade-r1', 'submitted': None, 'answers': {}}, open(os.path.join(d, 'r1.answers.json'), 'w')) + self.assertEqual(check_contract(load_spec(p)), []) + + def test_skipped_step_is_not_a_source(self): + from deck.contract import check_contract + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][1].update({'source': 'arcade-r1#S-2'})) + self.assertTrue(any('R2: step S-2 of arcade-r1 was not answered' in x for x in check_contract(s))) + + def test_unknown_step_and_missing_guard(self): + from deck.contract import check_contract + s = spec_with(self.tmp, lambda r: (r['steps'][0]['rows'][0].update({'source': 'arcade-r1#S-9'}), + r['steps'][0]['rows'][2].update({'guard': 'scripts/nope.py'}))) + problems = check_contract(s) + self.assertTrue(any('R1: no step "S-9" in r1.json' in x for x in problems), problems) + self.assertTrue(any('R3: guard scripts/nope.py is neither on disk under' in x for x in problems), problems) + + def test_guard_committed_on_the_branch_counts(self): + # A feature's mechanical rows mostly name tests the feature ADDS. From a worktree the + # workspace root is the main checkout, where that file does not exist until merge — so + # the check also looks on the contract's branch. Uncommitted still does not count. + import subprocess + from unittest import mock + from deck.contract import check_contract, guard_exists + root = os.path.join(self.tmp, 'ws'); os.makedirs(os.path.join(root, 'scripts')) + g = lambda *a: subprocess.run(['git', '-C', root, *a], check=True, capture_output=True, text=True) + g('init', '-q', '-b', 'main'); g('config', 'user.email', 't@t'); g('config', 'user.name', 't') + open(os.path.join(root, 'README'), 'w').write('x'); g('add', 'README'); g('commit', '-qm', 'base') + g('checkout', '-qb', 'feat/x') + open(os.path.join(root, 'scripts', 'guard.py'), 'w').write('# guard'); g('add', 'scripts/guard.py'); g('commit', '-qm', 'guard') + g('checkout', '-q', 'main') # back on main: the guard is NOT on disk + self.assertFalse(os.path.exists(os.path.join(root, 'scripts', 'guard.py'))) + self.assertTrue(guard_exists(root, 'feat/x', 'scripts/guard.py')) + self.assertFalse(guard_exists(root, 'main', 'scripts/guard.py')) + self.assertFalse(guard_exists(root, 'feat/x', 'scripts/uncommitted.py')) + with mock.patch.dict(os.environ, {'YOUCODED_WORKSPACE': root}): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][2].update({'guard': 'scripts/guard.py'}), branch='feat/x') + self.assertEqual(check_contract(s), []) + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][2].update({'guard': 'scripts/guard.py'}), branch='main') + self.assertTrue(any('R3: guard scripts/guard.py is neither on disk under' in x for x in check_contract(s))) + + def test_signoff_is_the_contracts_own_answer(self): + from deck.contract import signoff + p = contract_spec(self.tmp); s = load_spec(p) + ok, line = signoff(s) + self.assertFalse(ok); self.assertIn('not signed', line) + ap = p.replace('.json', '.answers.json') + json.dump({'deck': 'arcade-contract', 'submitted': None, 'answers': {'C': {'v': 'yes'}}}, open(ap, 'w')) + ok, line = signoff(s) + self.assertFalse(ok); self.assertIn('not signed', line) # answered but never submitted + json.dump({'deck': 'arcade-contract', 'submitted': '2026-09-01T11:00:00Z', 'answers': {'C': {'v': 'no', 'note': 'R2 is wrong'}}}, open(ap, 'w')) + ok, line = signoff(s) + self.assertFalse(ok); self.assertIn('answered "no"', line); self.assertIn('R2 is wrong', line) + json.dump({'deck': 'arcade-contract', 'submitted': '2026-09-01T11:00:00Z', 'answers': {'C': {'v': 'yes'}}}, open(ap, 'w')) + ok, line = signoff(s) + self.assertTrue(ok); self.assertIn('signed 2026-09-01 11:00', line) + + def test_acceptance_status(self): + from deck.contract import acceptance_status + p = contract_spec(self.tmp); s = load_spec(p); d = os.path.dirname(p) + ok, line = acceptance_status(s) + self.assertFalse(ok); self.assertIn('acceptance deck not built', line) + json.dump({'key': 'x', 'steps': []}, open(os.path.join(d, 'arcade.contract.acceptance.json'), 'w')) + ok, line = acceptance_status(s) + self.assertFalse(ok); self.assertIn('acceptance deck not submitted', line) + json.dump({'submitted': '2026-09-01T12:00:00Z', 'answers': {'C': {'v': 'yes'}}}, open(os.path.join(d, 'arcade.contract.acceptance.answers.json'), 'w')) + ok, line = acceptance_status(s) + self.assertTrue(ok); self.assertIn('acceptance deck submitted 2026-09-01 12:00', line) + + def test_cli_contract_check(self): + import importlib.util + spec_ = importlib.util.spec_from_file_location('review_cards', os.path.join(os.path.dirname(HERE), 'review-cards.py')) + rc = importlib.util.module_from_spec(spec_); spec_.loader.exec_module(rc) + import io + from contextlib import redirect_stderr, redirect_stdout + p = contract_spec(self.tmp) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = rc.main(['contract-check', p]) + self.assertEqual(code, 0, err.getvalue()) + lines = out.getvalue().splitlines() + self.assertTrue(lines[0].startswith('ok: contract holds: 3 rows'), lines) + self.assertTrue(lines[1].startswith('todo: not signed'), lines) + self.assertTrue(lines[2].startswith('todo: acceptance deck not built'), lines) + # A source problem is exit 1 with the problems on stderr and nothing on stdout. + ap = os.path.join(os.path.dirname(p), 'r1.answers.json') + a = json.load(open(ap)); a['submitted'] = None; json.dump(a, open(ap, 'w')) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = rc.main(['contract-check', p]) + self.assertEqual(code, 1); self.assertIn('never submitted', err.getvalue()); self.assertEqual(out.getvalue(), '') + + +class AcceptanceTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_refuses_without_verdicts_for_graded_rows(self): + from deck.contract import acceptance_spec, AcceptanceError + s = load_spec(contract_spec(self.tmp)) + with self.assertRaises(AcceptanceError) as cm: + acceptance_spec(s, {'R1': {'verdict': 'pass', 'evidence': 'answered a'}}) + self.assertIn('R3 (mechanical) has no verdict', str(cm.exception)) + + def test_builds_the_acceptance_deck(self): + from deck.contract import acceptance_spec + s = load_spec(contract_spec(self.tmp)) + acc = acceptance_spec(s, {'R1': {'verdict': 'pass', 'evidence': 'answered a'}, + 'R3': {'verdict': 'fail', 'evidence': 'test_contract.py: 1 failed'}}) + self.assertEqual(acc['key'], 'arcade-contract-acceptance') + self.assertEqual([st['id'] for st in acc['steps']], ['C', 'R2']) + c, r2 = acc['steps'] + self.assertEqual([r.get('verdict') for r in c['rows']], ['pass', None, 'fail']) + self.assertTrue(r2['words']); self.assertEqual((r2['yes'], r2['no']), ('Holds', 'Fails')) + self.assertEqual(r2['headline'], "A second player's board is tellable from mine at a glance.") + self.assertIn('band could be thinner', r2['changed']) + # It is itself a valid deck. + d = os.path.dirname(contract_spec(self.tmp)) + ap = os.path.join(d, 'arcade.contract.acceptance.json'); json.dump(acc, open(ap, 'w')) + self.assertEqual(validate(load_spec(ap))[0], []) +``` + +Run: `cd scripts/ui-review/tests && python3 -m unittest test_contract -v` +Expected: the new cases fail with `ModuleNotFoundError: No module named 'deck.contract'`. + +- [ ] **Step 2: contract.py** + +Create `scripts/ui-review/deck/contract.py`: + +```python +"""The gate's three facts, and the acceptance deck built from the contract. + +contract-check reads what the design calls the gate (feature-flow design §4): (1) the contract +holds — every row's `source` names a step that exists in a deck the spec's `sources` map points +at, that deck's answers were SUBMITTED, that step was answered (not skipped), and every +`mechanical` guard exists on disk or on the contract's branch; (2) the contract was SIGNED — +its own answers file is submitted and the contract step answered yes; (3) the acceptance deck +was submitted. Only (1) is an exit code: the contract agent runs this before Destin has seen +the deck, so (2) and (3) are reported as `ok:` / `todo:` lines that close-out.sh relays. + +acceptance merges the grader's verdicts into the contract: step 1 is the table with a verdict +beside every graded row, then one words step per human / live-app row for Destin to tick.""" +import glob +import json +import os +import subprocess + +from .spec import is_contract, workspace_root + + +class AcceptanceError(Exception): + pass + + +def contract_steps(spec): + return [st for st in spec['steps'] if is_contract(st)] + + +def _when(stamp): + return (stamp or '')[:16].replace('T', ' ') + + +def guard_exists(root, branch, guard): + """A `mechanical` row's guard, as a workspace-relative path. True if it is on disk under + `root`, or committed on `branch` (or `origin/`) in the repo the path's first + segment names — `youcoded/desktop/tests/x.test.ts` is looked up in the `youcoded` repo as + `desktop/tests/x.test.ts`; `scripts/x.py` in the workspace repo itself. + WHY the branch: from a worktree, workspace_root() is the MAIN checkout, where a test the + feature branch adds does not exist until merge — and those are most of the guards a + contract names. An uncommitted file is found nowhere, on purpose.""" + if not guard: + return False + if os.path.exists(os.path.join(root, guard)): + return True + if not branch: + return False + first, _, rest = guard.partition('/') + repo, rel = (os.path.join(root, first), rest) if rest and os.path.exists(os.path.join(root, first, '.git')) else (root, guard) + for ref in (branch, f'origin/{branch}'): + r = subprocess.run(['git', '-C', repo, 'cat-file', '-e', f'{ref}:{rel}'], capture_output=True) + if r.returncode == 0: + return True + return False + + +def answers_for(spec_path): + """(raw spec, newest SUBMITTED answers, why) for a source deck. Returns (None, None, why) + when the spec cannot be read; (spec, None, why) when nothing submitted exists. + WHY the glob: serve.rotate_submitted moves a submitted file to .answers..json + before a re-serve, so the plain file may be the EMPTY new one while the decisions sit in + the stamped one. The newest submitted file wins, whichever name it carries.""" + try: + with open(spec_path) as f: + raw = json.load(f) + except (OSError, ValueError) as e: + return None, None, f'cannot read {os.path.basename(spec_path)}: {e}' + base, stem = os.path.dirname(spec_path), os.path.splitext(os.path.basename(spec_path))[0] + candidates = [os.path.join(base, stem + '.answers.json')] + sorted(glob.glob(os.path.join(base, stem + '.answers.*.json')), reverse=True) + seen_any = False + for c in candidates: + try: + with open(c) as f: + a = json.load(f) + except (OSError, ValueError): + continue + seen_any = True + if a.get('submitted'): + return raw, a, '' + rel = os.path.basename(spec_path) + return raw, None, (f'{rel} answers were never submitted' if seen_any else f'{rel} has no answers file') + + +def check_contract(spec): + """One problem per line; empty means the contract holds together.""" + problems, cache = [], {} + sources = spec.get('sources') or {} + root = workspace_root() + for st in contract_steps(spec): + for r in st['rows']: + tag = f'{st["id"]}/{r["id"]}' + key, _, sid = (r.get('source') or '').partition('#') + rel = sources.get(key) + if not rel: + problems.append(f'{tag}: source deck "{key}" is not in the spec\'s "sources"') + continue + if key not in cache: + cache[key] = answers_for(os.path.join(spec['_base'], rel)) + raw, ans, why = cache[key] + if raw is None: + problems.append(f'{tag}: {why}') + continue + if raw.get('key') != key: + problems.append(f'{tag}: {rel} is deck "{raw.get("key")}", not "{key}"') + if sid not in {s.get('id') for s in raw.get('steps', [])}: + problems.append(f'{tag}: no step "{sid}" in {rel}') + if ans is None: + problems.append(f'{tag}: {why}') + continue + a = (ans.get('answers') or {}).get(sid) or {} + if not a.get('v') or a['v'] == 'skip': + problems.append(f'{tag}: step {sid} of {key} was not answered') + if r.get('checkedBy') == 'mechanical' and not guard_exists(root, spec.get('branch'), r.get('guard', '')): + problems.append(f'{tag}: guard {r.get("guard")} is neither on disk under {root} nor committed on branch "{spec.get("branch") or "(no branch in the spec)"}"') + return problems + + +def signoff(spec): + """Fact (2): the contract's OWN answers — submitted, and the contract step answered yes. + Returns (ok, one line for close-out).""" + steps = contract_steps(spec) + sid = steps[0]['id'] if steps else None + _, ans, why = answers_for(os.path.join(spec['_base'], spec['_stem'] + '.json')) + if ans is None: + return False, f'not signed — {why}; serve {spec["_stem"]}.json and answer it' + a = (ans.get('answers') or {}).get(sid) or {} + if a.get('v') == 'yes': + return True, f'signed {_when(ans.get("submitted"))} — {sid} yes' + (f' — "{a["note"].strip()}"' if (a.get('note') or '').strip() else '') + if a.get('v') in ('no', 'other'): + return False, f'answered "{a["v"]}" {_when(ans.get("submitted"))} — the contract is not agreed' + (f': "{a["note"].strip()}"' if (a.get('note') or '').strip() else '') + return False, f'not signed — submitted {_when(ans.get("submitted"))} but step {sid} was skipped' + + +def acceptance_status(spec): + """Fact (3): `.acceptance.json` exists and its newest answers file is submitted.""" + acc = os.path.join(spec['_base'], spec['_stem'] + '.acceptance.json') + if not os.path.exists(acc): + return False, f'acceptance deck not built — write {spec["_stem"]}.verdicts.json, then review-cards.py acceptance {spec["_stem"]}.json' + _, ans, why = answers_for(acc) + if ans is None: + return False, f'acceptance deck not submitted — serve {os.path.basename(acc)}' + return True, f'acceptance deck submitted {_when(ans.get("submitted"))}' + + +GRADED = ('mechanical', 'deck') + + +def acceptance_spec(spec, verdicts): + """The acceptance deck as a spec dict. `verdicts` is {row id: {verdict, evidence}} from + .verdicts.json. Refuses when a graded row has none: an ungraded row is not a pass.""" + steps = contract_steps(spec) + if len(steps) != 1: + raise AcceptanceError(f'expected exactly one contract step, found {len(steps)}') + st = steps[0] + missing = [f'{r["id"]} ({r["checkedBy"]})' for r in st['rows'] if r.get('checkedBy') in GRADED and not (verdicts.get(r['id']) or {}).get('verdict')] + if missing: + raise AcceptanceError('no verdict for graded rows: ' + ', '.join(m + ' has no verdict' for m in missing)) + rows = [] + for r in st['rows']: + v = verdicts.get(r['id']) or {} + rows.append({**r, **({'verdict': v['verdict'], 'evidence': v.get('evidence', '')} if v.get('verdict') else {})}) + table = {**st, 'id': st['id'], 'rows': rows, 'headline': 'The contract, graded — accept these verdicts?', + 'yes': 'Yes, accept', 'no': 'No, something is wrong'} + human = [{'id': r['id'], 'words': True, 'surface': st['surface'], 'path': 'Acceptance', + 'headline': r['statement'], + 'changed': 'Checked by you.' + (f' Your note at review: “{r["note"]}”' if r.get('note') else ''), + 'notice': r.get('threshold') or 'pass / fail', + 'yes': 'Holds', 'no': 'Fails'} + for r in st['rows'] if r.get('checkedBy') in ('human', 'live-app')] + return {'title': spec['title'] + ' — acceptance', 'key': spec['key'] + '-acceptance', + 'out': spec['_stem'] + '.acceptance.html', 'themes': list(spec['themes']), + 'branch': spec.get('branch', ''), 'sources': dict(spec.get('sources') or {}), + 'steps': [table] + human} +``` + +- [ ] **Step 3: CLI** + +In `review-cards.py`: import `from deck.contract import AcceptanceError, acceptance_spec, acceptance_status, check_contract, contract_steps, signoff`; register `for c in ('build', 'serve', 'wait', 'contract-check', 'acceptance'):`; in `main` before the `serve` branch: + +```python + if a.cmd == 'contract-check': + if not contract_steps(spec): + print('no contract step in this spec (a step with "rows")', file=sys.stderr) + return 1 + problems = check_contract(spec) + if problems: + print('\n'.join(problems), file=sys.stderr) + return 1 + n = sum(len(st['rows']) for st in contract_steps(spec)) + # Three facts, three lines, `ok:`/`todo:` prefixed so close-out.sh can relay them + # without parsing anything. Only the first is an exit code (see contract.py). + print(f'ok: contract holds: {n} rows, every source answered and submitted, every guard found') + for ok, line in (signoff(spec), acceptance_status(spec)): + print(('ok: ' if ok else 'todo: ') + line) + return 0 + if a.cmd == 'acceptance': + vpath = os.path.join(spec['_base'], spec['_stem'] + '.verdicts.json') + try: + with open(vpath) as f: + verdicts = json.load(f) + except OSError: + print(f'no verdicts file at {vpath} — the grader writes {{rowId: {{verdict, evidence}}}} there first', file=sys.stderr) + return 1 + try: + acc = acceptance_spec(spec, verdicts) + except AcceptanceError as e: + print(str(e), file=sys.stderr) + return 1 + out = os.path.join(spec['_base'], spec['_stem'] + '.acceptance.json') + with open(out, 'w') as f: + json.dump(acc, f, indent=1) + print('wrote', out, '— now: review-cards.py serve', out) + return 0 +``` + +Add `import json` at the top and the two commands to the docstring: + +``` + python3 scripts/ui-review/review-cards.py contract-check .contract.json + every row's source resolves to an answered step in a submitted deck and every mechanical guard exists on disk or on + the contract's branch (exit 1 lists what doesn't); then reports, as ok:/todo: lines, whether the contract was signed + (its own answers file) and whether the acceptance deck was submitted + python3 scripts/ui-review/review-cards.py acceptance .contract.json + merge .contract.verdicts.json into .contract.acceptance.json — the contract graded, plus a yes/no per human row +``` + +- [ ] **Step 4: Run and commit** + +Run: `cd scripts/ui-review/tests && python3 -m unittest test_contract -v` +Expected: 21 tests pass. + +```bash +git add scripts/ui-review/deck/contract.py scripts/ui-review/review-cards.py scripts/ui-review/tests/test_contract.py +git commit -m "feat(deck): contract-check reads the gate's three facts; acceptance builds the graded deck + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" +``` + +--- + +### Task 5: `close-out.sh` Contract section + +**Files:** +- Modify: `scripts/close-out.sh` (insert before `echo "Docs"`, line ~118; add `DOCS_DIR` override near line 22) +- Create: `scripts/ui-review/tests/close-out-contract.test.sh` + +**Interfaces:** +- Consumes: `review-cards.py contract-check` (Task 4) — its exit code and its `ok:` / `todo:` lines; a contract spec's top-level `"branch"`. +- Produces: a `Contract` section with `OK` / `TODO` / `--` lines, always exit 0. No JSON is read here; every fact comes from `contract-check`. + +- [ ] **Step 1: The test** + +Create `scripts/ui-review/tests/close-out-contract.test.sh`: + +```bash +#!/usr/bin/env bash +# close-out.sh gets a Contract section: no contract → a note; a contract that holds but is +# unsigned with no acceptance deck → OK + TODO + TODO; signed and accepted → three OKs; a +# contract that does not hold → TODO with the problems indented. Runs against a temp docs dir. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; WS="$(cd "$HERE/../../.." && pwd)" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +python3 -c "import sys; sys.path.insert(0, '$HERE'); from fixture import contract_spec; print(contract_spec('$TMP'))" >/dev/null +X="$TMP/docs/active/design/x"; mkdir -p "$X" && mv "$TMP/deck/"* "$X/" + +out=$(CLOSE_OUT_DOCS="$TMP/nothing" bash "$WS/scripts/close-out.sh" no-such-branch-zz workspace) +grep -q "^Contract" <<<"$out" || { echo "no Contract section"; exit 1; } +grep -q "no contract names this branch" <<<"$out" || { echo "missing 'no contract' note"; echo "$out"; exit 1; } + +# pass()/fail() print colour escapes between the OK/TODO word and the message, so match loosely. +out=$(CLOSE_OUT_DOCS="$TMP/docs" bash "$WS/scripts/close-out.sh" feat/arcade-fixture workspace) +grep -q "OK.*contract holds: 3 rows" <<<"$out" || { echo "expected 'contract holds'"; echo "$out"; exit 1; } +grep -q "TODO.*not signed" <<<"$out" || { echo "expected unsigned TODO"; echo "$out"; exit 1; } +grep -q "TODO.*acceptance deck not built" <<<"$out" || { echo "expected acceptance TODO"; echo "$out"; exit 1; } + +echo '{"submitted":"2026-09-01T11:00:00Z","answers":{"C":{"v":"yes"}}}' > "$X/arcade.contract.answers.json" +echo '{"key":"arcade-contract-acceptance","steps":[]}' > "$X/arcade.contract.acceptance.json" +echo '{"submitted":"2026-09-01T12:00:00Z","answers":{"C":{"v":"yes"},"R2":{"v":"yes"}}}' > "$X/arcade.contract.acceptance.answers.json" +out=$(CLOSE_OUT_DOCS="$TMP/docs" bash "$WS/scripts/close-out.sh" feat/arcade-fixture workspace) +grep -q "OK.*signed 2026-09-01 11:00" <<<"$out" || { echo "expected signed OK"; echo "$out"; exit 1; } +grep -q "OK.*acceptance deck submitted" <<<"$out" || { echo "expected acceptance OK"; echo "$out"; exit 1; } + +python3 - "$X/r1.answers.json" <<'PY' +import json, sys; p = sys.argv[1]; a = json.load(open(p)); a['submitted'] = None; json.dump(a, open(p, 'w')) +PY +out=$(CLOSE_OUT_DOCS="$TMP/docs" bash "$WS/scripts/close-out.sh" feat/arcade-fixture workspace) +grep -q "TODO.*contract does not hold" <<<"$out" || { echo "expected does-not-hold TODO"; echo "$out"; exit 1; } +grep -q "never submitted" <<<"$out" || { echo "expected the problem line"; echo "$out"; exit 1; } +echo "close-out contract section: ok" +``` + +Run: `bash scripts/ui-review/tests/close-out-contract.test.sh` +Expected: `no Contract section`, exit 1. + +- [ ] **Step 2: The section** + +In `scripts/close-out.sh`, after `WORKSPACE=…` (line 22) add: + +```bash +# Where contracts are looked for. Overridable so the test can point it at a temp folder. +DOCS_DIR="${CLOSE_OUT_DOCS:-$WORKSPACE/docs}" +``` + +Insert before `echo "Docs"`: + +```bash +echo +echo "Contract" +# The contract is the definition of done for a feature (docs/active/specs/2026-09-01-feature-flow-design.md). +# It names its branch, so this is the ONLY lookup — no "branch" field, no contract, and the +# note below says so rather than guessing which deck folder this work came from. +CONTRACTS=$(rg -l --glob '*.contract.json' -F "\"branch\": \"$BRANCH\"" "$DOCS_DIR" 2>/dev/null || true) +if [[ -z "$CONTRACTS" ]]; then + note "no contract names this branch — the feature flow was not used, or the contract has no \"branch\"" +else + while IFS= read -r c; do + REL="${c#"$WORKSPACE"/}" + # contract-check owns every fact (does it hold, was it signed, was acceptance submitted): + # exit 1 + problems on stderr when it does not hold; otherwise `ok:` / `todo:` lines + # that are relayed here verbatim, so this script never reads an answers file itself. + if OUT=$(python3 "$WORKSPACE/scripts/ui-review/review-cards.py" contract-check "$c" 2>&1); then + while IFS= read -r line; do + case "$line" in + ok:\ *) pass "${line#ok: } — $REL" ;; + todo:\ *) fail "${line#todo: }" ;; + *) note "$line" ;; + esac + done <<<"$OUT" + else + fail "contract does not hold — $REL:" + echo "$OUT" | sed 's/^/ /' + fi + done <<<"$CONTRACTS" +fi +``` + +(Rotated answers files — `.answers..json` after a re-serve — are handled inside `contract-check` by `answers_for`, for the sign-off and the acceptance deck alike.) + +- [ ] **Step 3: Run, register, commit** + +Run: `bash scripts/ui-review/tests/close-out-contract.test.sh` +Expected: `close-out contract section: ok`. + +Add the test to the README's local block (`bash scripts/ui-review/tests/close-out-contract.test.sh` — local because `close-out.sh` shells out to `rg` and runs `git fetch`, neither of which the CI runner is set up for) and to the close-out header comment (`# Contract section: feature-flow design §4`). + +```bash +git add scripts/close-out.sh scripts/ui-review/tests/close-out-contract.test.sh scripts/ui-review/README.md +git commit -m "feat(close-out): a Contract section — does the contract hold, was the acceptance deck submitted + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" +``` + +--- + +### Task 6: The contract agent prompt, dry-run on the arcade + +**Files:** +- Create: `scripts/ui-review/contract-agent.md` +- Dry-run output (NOT committed): `scratch/feature-flow/games-arcade.contract.json`. It is a test of the prompt, not a deliverable — and a contract in `docs/` naming `feat/games-arcade-shell` would make `close-out.sh` report an unsigned contract on a branch that closed out on 2026-08-31, forever. + +**Interfaces:** +- Consumes: Task 0's committed answers files, Task 3's template, Task 4's `contract-check`. +- Produces: the prompt every implementing session dispatches (with the `Agent` tool, fresh context) to write a contract. + +- [ ] **Step 1: The prompt** + +Create `scripts/ui-review/contract-agent.md`: + +```markdown +# Contract agent + +You write the contract for a feature: the rows that define "done", built ONLY from what Destin +answered on the decks. You are a fresh agent on purpose — the session that drew the designs +grades its own work generously; you do not. + +## Inputs (you get nothing else) +- Every deck spec for the feature, in order: `.questions.json`, then each review round. +- Their answers files (`*.answers.json`; if a stamped `*.answers..json` exists and the + plain file is unsubmitted, the stamped one is the real answer set). +- `scripts/ui-review/templates/contract.json` — the shape to fill. + +Do NOT read the design spec, the implementation plan, chat transcripts or the code. If the +answers do not support a row, the row does not exist; write what was missed into a +`## Not covered` list at the end of your reply so the next round can ask. + +## How an answer becomes a row +- `yes` with no note, or a note tagged **just noting** → one row. Statement = the step's + headline rewritten as what the user experiences (present tense, no code words — the deck's + banned list applies). `source` = `#`; `note` = the note text verbatim. +- `pick X` → a row stating the picked option's label as a fact ("The invite lives in the + friends list"). Other options are not rows. +- `other` → a row from the note ONLY if it states a requirement; a wish or a question is + `## Not covered`. +- A note tagged **fix now** → NOT a row (it was the next round's work; the next round's + answer is the source). Tagged **fix later** → not a row; list it under `## Roadmap` in your + reply with the source, for the session to file. +- `no` / `skip` → no row. A skipped step is unanswered, never "fine". + +## `checkedBy` +- `mechanical` only when you can name a test or guard path (workspace-relative) that checks + the statement and EXISTS — on disk, or committed on the feature branch you were told + (`contract-check` looks in both places). Do not invent one; if none exists, the row is + `human` and you say so in `## Not covered` ("R4 needs a test"). +- `deck` when the approved step's picture IS the check (re-shot from the built branch). +- `live-app` when only the real running app can show it (sync, other users, terminals). +- `human` otherwise. + +## Rules +- One sentence per statement, in the user's words. ≤ 25 words. +- `threshold` is pass/fail unless a number was approved on the deck (a `measured` field). +- Set `branch` to the feature branch you were told; `sources` maps every deck key you cite to + its spec path relative to the contract file. +- Finish with: `python3 scripts/ui-review/review-cards.py contract-check ` and paste its + output. A contract that does not hold (exit 1) is not delivered; the `todo: not signed` + line is expected — signing is Destin's, after you. +``` + +- [ ] **Step 2: Dry run** + +Dispatch a fresh `Agent` (general-purpose) with the prompt file, the three arcade specs and answers under `docs/archive/design/2026-08-30-games-arcade/` (`step1-sizing`, `board-contrast`, `head-to-head`), branch `feat/games-arcade-shell`, output path `scratch/feature-flow/games-arcade.contract.json` (`sources` paths are relative to the contract file, so they point back into `docs/archive/…`). (The arcade had no questions deck; say so in the dispatch.) + +Then run: `python3 scripts/ui-review/review-cards.py contract-check scratch/feature-flow/games-arcade.contract.json` +Expected: exit 0, `ok: contract holds: N rows …`, `todo: not signed …`, `todo: acceptance deck not built …`. Read the rows: a reader who knows the arcade should recognise it (the board fills the pane; a second player's board is tellable; the head-to-head layout). If a row is not traceable to a `yes`/`pick`, the prompt is wrong — fix the prompt, not the output. Paste the rows and the check output into the commit message below; the file itself stays in `scratch/`. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/ui-review/contract-agent.md +git commit -m "feat(deck): the contract agent prompt, dry-run against the arcade's three decks + + + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" +``` + +--- + +### Task 7: The rule, the skill, the docs + +**Files:** +- Create: `.claude/rules/feature-flow.md` +- Modify: `.claude/skills/ui-mockup/SKILL.md:78-88` (After approval) +- Modify: `scripts/ui-review/README.md` (deck section, ~line 62 — one paragraph) +- Modify: `docs/MAP.md:19` (rule column: `react-renderer · feature-flow`) +- Modify: `CLAUDE.md` "New Features & UI/UX Changes" (one sentence) +- Modify: `ROADMAP.md` (the entry for this work, under Features) + +- [ ] **Step 1: The rule** + +Create `.claude/rules/feature-flow.md`: + +```markdown +--- +paths: + - "**/scripts/ui-review/deck/**" + - "**/scripts/ui-review/review-cards.py" + - "**/scripts/ui-review/contract-agent.md" + - "**/docs/active/design/**" + - "**/scripts/close-out.sh" +last_verified: 2026-09-01 +verify: + - path: scripts/ui-review/deck/contract.py + contains: "def check_contract" + - path: scripts/ui-review/contract-agent.md + - test: scripts/ui-review/tests/test_contract.py + - test: scripts/ui-review/tests/test_words.py +--- + +# Feature flow — the deck is the one surface + +Design: `docs/active/specs/2026-09-01-feature-flow-design.md`. + +## Questions before drawing +**Invariant:** a new feature's step-2 questions are a words-only deck (`.questions.json`, +`"words": true` decide steps, 1–3 options), served and submitted before any UI is drawn. +**Why:** answers in chat are not a source; a contract row must resolve to an answered step. +**Guard:** `test_words.py`; the `ui-mockup` skill's checklist. + +## The contract is a deck, and its sources are answered steps +**Invariant:** `.contract.json` is a one-step `rows` deck; every row's `source` is +`#` of a submitted, non-skipped answer. Not the design spec, not the plan, +not the transcript. Written by a FRESH agent from `scripts/ui-review/contract-agent.md`. +**Why:** provenance — the rows are Destin's decisions, and a generator grading itself is generous. +**Guard:** `review-cards.py contract-check`; `test_contract.py`. + +## Answers files are committed +**Invariant:** `docs/**/*.answers.json` (and the stamped rotations) are tracked; only `scratch/` +is ignored. Never add them back to `.gitignore`. +**Why:** they are the only record of decisions; ignored for three months, they lived on one disk. +**Guard:** none — candidate (an anchor test on `.gitignore`). + +## Reopen only through a deck +**Invariant:** when implementation contradicts approved UI, the implementing session serves a +one-step words-only `decide` deck and waits; a chat question is not a route back. The answer +amends the contract row's `source`. +**Why:** a chat answer is not a source (see above). +**Guard:** none — candidate. + +## The gate is three facts, and one command reports them +**Invariant:** `review-cards.py contract-check .contract.json` is the only reader of +the gate: (1) every row's source resolves and every `mechanical` guard exists on disk or on +the contract's `branch` (exit 1 otherwise); (2) the contract was signed — `.contract.answers.json` +submitted with the contract step `yes`; (3) `.contract.acceptance.answers.json` is +submitted. `close-out.sh` relays its `ok:` / `todo:` lines and reads no answers file itself. +**Why:** a guard the branch adds is not in the main checkout until merge; a contract nobody +signed is not a definition of done; two readers of one file drift. +**Guard:** `test_contract.py` (ContractCheckTests); `close-out-contract.test.sh`. + +## Acceptance is graded rows plus human rows +**Invariant:** the grader writes `.contract.verdicts.json` (beside the contract, same +stem — the CLI reads exactly that name); `review-cards.py acceptance` refuses when a +`mechanical` or `deck` row has no verdict. +**Why:** an ungraded row is not a pass. +**Guard:** `test_contract.py` (AcceptanceTests). +``` + +- [ ] **Step 2: The skill** + +In `.claude/skills/ui-mockup/SKILL.md`, insert before `## The mechanism: edit the real components` (line 13): + +```markdown +## Before drawing anything: the questions deck + +Step 2 of the feature flow (`docs/active/specs/2026-09-01-feature-flow-design.md` §5) is a +deck, not a chat. Write `docs/active/design/-/.questions.json` — one +`"words": true` step per question, one to three options (the recommended one first, its why in +`summary`), no picture — and `serve` it in the background. Do not ask what the design guide or +the code already answers; do not ask what has an obvious answer (state it, the review deck +will show it). Draw only after it is submitted: its answers are the first source of the +contract. +``` + +Replace the `## After approval` list (lines 80–88) with: + +```markdown +## After approval + +Decisions must not live only in chat — and the deck answers ARE the record (they are committed): + +1. **Write the contract.** Dispatch a fresh agent with `scripts/ui-review/contract-agent.md`, + the questions deck, every round's spec and answers, and the branch name. Serve + `.contract.json`; it is the last thing Destin answers before the build. Run + `review-cards.py contract-check` on it and paste the output into the handoff. +2. Turn the `MOCK_ONLY` entries the approved UI depends on into real handlers (main + + `preload.ts` + `remote-shim.ts` + `SessionService.kt`, guarded by `ipc-channels.test.ts`), + then drop them from the registry. +3. A design spec under `docs/active/specs/` is written only when the work crosses repos, + touches a migration or a protocol, or has ordering constraints (design §8). Otherwise the + contract plus the approved decks is the plan. +4. Add ROADMAP entries for every *fix later* note the contract agent listed, and follow the + workspace knowledge rules (pinning test > ast-grep rule > WHY comment > path-scoped rule). +5. At the end: write `.contract.verdicts.json` beside the contract, run + `review-cards.py acceptance`, serve the acceptance deck; `bash scripts/close-out.sh ` + reports whether the contract holds, was signed, and was accepted. + +Merging cannot shift appearance, because nothing was ever copied. +``` + +- [ ] **Step 3: README, MAP, CLAUDE.md, ROADMAP** + +- `scripts/ui-review/README.md` deck row: append one sentence naming words-only steps, the contract step, `contract-check` and `acceptance`, pointing at the design doc. +- `docs/MAP.md:19` rule column → `react-renderer · feature-flow`; add `contract-check` / `acceptance` to the entry-points cell. +- `CLAUDE.md` → "New Features & UI/UX Changes": after the review-deck sentence add: `The flow around the deck — questions deck first, contract at sign-off, acceptance deck at the end — is `.claude/rules/feature-flow.md`.` +- `ROADMAP.md` under `## Features`: `- [ ] \`feature\` \`#workspace\` \`#ui-review\` **Feature flow: questions deck → review rounds → contract → acceptance, with contract-check in close-out** — design \`docs/active/specs/2026-09-01-feature-flow-design.md\`, plan \`docs/active/plans/2026-09-01-feature-flow-plan.md\`. Four assumptions await Destin's veto (design §9). (added 2026-09-01)` + +- [ ] **Step 4: Verify the anchors and the rule firing** + +Run: `node scripts/audit-anchors.mjs` +Expected: green (the new rule's `verify:` entries resolve). + +In a fresh Claude session, Read `worktrees/feature-flow/scripts/ui-review/deck/contract.py` through the Read tool (a shell `touch` or `cat` loads no rule — only Claude's own file tools do), then `tail -3 ~/.claude/instructions-loaded.log` — expect a `feature-flow.md` line (the `**/` glob fires inside the worktree). If no session is at hand, note it in the handoff as unverified. + +- [ ] **Step 5: Commit** + +```bash +git add .claude/rules/feature-flow.md .claude/skills/ui-mockup/SKILL.md scripts/ui-review/README.md docs/MAP.md CLAUDE.md ROADMAP.md +git commit -m "docs(feature-flow): the rule, the skill's questions-deck and contract steps, MAP and README pointers + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" +``` + +--- + +### Task 8: Ask the design's own questions on the first questions deck, then run it once end to end + +**Files:** +- Create: `docs/active/design/2026-09-01-feature-flow/feature-flow.questions.json` + +- [ ] **Step 1: The design's four assumptions become the first questions deck** + +The design (§9) leaves four assumptions for Destin to veto, and Task 1 built exactly the deck meant for that — so it is used here rather than a chat question. Write `feature-flow.questions.json` (`key: feature-flow-questions`, `title: Feature flow — four assumptions`, `themes: ["midnight", "light"]`): four `"words": true` decide steps, `surface: "Feature flow"`, `path: "Design §9"`, ids `Q-1`…`Q-4`, one per §9 question, the design's assumption as the first (recommended) option with its why in `summary`, the alternative second, both in the deck's plain words. Q-1 acceptance deck (keep it / fold human rows into the contract); Q-2 reopen with a default (proceed on a marked default / always stop); Q-3 plan documents only for cross-repo, migration or ordering work (yes / always write one); Q-4 commit answers files (commit / copy beside the contract at sign-off). Validate with `review-cards.py build`. + +- [ ] **Step 2: Serve it without opening a window, and do not wait** + +```bash +mkdir -p scratch && python3 scripts/ui-review/review-cards.py serve docs/active/design/2026-09-01-feature-flow/feature-flow.questions.json --no-open --timeout 720 > scratch/feature-flow-questions.serve.log 2>&1 & +sleep 2 && rg -n '\[deck\] http' scratch/feature-flow-questions.serve.log +``` + +Put the printed URL in the final message to Destin. Everything in Tasks 0–7 was built under the design's assumptions; a veto on this deck is the first reopen (design §6) and is acted on by the session that sees the submit. Do NOT open his browser (memory: warn before opening windows). + +- [ ] **Step 3: Commit the spec** + +```bash +git add docs/active/design/2026-09-01-feature-flow/feature-flow.questions.json +git commit -m "docs(feature-flow): the design's four assumptions as the first questions deck + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" +``` + +- [ ] **Step 4: The first real run** + +Not a code task. The next small UI feature Destin asks for runs through the whole flow: questions deck → rounds → contract → build → verdicts → acceptance → `close-out.sh`. The handoff for that feature records, from the answers files on disk: + +| Number | Where it comes from | +|---|---| +| rounds | count of `-r*.json` | +| Destin-seconds | sum of `seconds` across every answers file | +| reopen decks | count of one-step decide decks served mid-build | +| rows failed at acceptance | `verdict: fail` rows + human `no` answers | +| what was skipped, what the questions missed | the contract agent's `## Not covered` list, and any row Destin answered `no` | + +Then `/wrap-up`. The design doc's `status:` flips to `active` on the first run and `shipped` when the flow has run twice without a chat question standing in for a deck. + +--- + +## Self-review + +**Spec coverage.** §2 defect (answers ignored) → Task 0. §3 contract format, who writes it → Tasks 3, 6. §4 gate: three facts, `contract-check`, close-out section, rotation → Tasks 4, 5. §5 questions deck (no picture, one option), note tags → Tasks 1, 2. §6 reopen → rule in Task 7 (the `default` option is an assumption; nothing built until Destin answers Q2 — a `default` field on a words decide step is a one-line addition to `_validate_words` when he does). §7 acceptance → Task 4. §8 plan tier → skill text in Task 7 (superseded 2026-09-02: §8 is now the build stage, and the skill's step 3 was rewritten on the branch to match); roadmap loop deferred by design. §10 deferred items have no tasks, by design. Measurement → Task 8. + +**Placeholders.** None: every code step carries its code. Task 3 Step 6 (the template) describes values rather than pasting a full JSON — acceptable because the fixture in Step 1 is the worked example, and the template is that fixture with instruction strings. + +**Type consistency.** `is_words` / `no_pictures` / `is_contract` / `_validate_options(st, sid, errors, warnings, minimum)` / `_validate_rows(spec, st, sid, errors)` are defined in Task 1/3 and used with those names in crops.py, build.py, contract.py and every test. Deck data keys `words`, `kind`, `yes`, `no`, `rows`, `options` match between build.py and page.js. `answers_for` returns the 3-tuple that `check_contract`, `signoff`, `acceptance_status` and the tests expect; `signoff` / `acceptance_status` return `(bool, str)` and the CLI prefixes them. `note_kind` values `now|later|noting` match page.js, serve.py `NOTE_KIND`, the fixture and the agent prompt. + +## Review changes (2026-09-01) + +A second session verified the plan against the code before execution. What changed, and why: + +- **The gate's third fact was missing.** The design's gate is three facts; the plan checked two. `contract-check` now also reports whether the contract's own answers file is submitted with a `yes`, and whether the acceptance deck was submitted — and `close-out.sh` relays those lines instead of reading JSON itself. +- **Guards now resolve on the branch.** `workspace_root()` from a worktree is the main checkout, so a test the feature adds was "missing" until merge. `guard_exists` also asks git for the file on the contract's `branch`. +- **One name for the verdicts file.** The design and docs said `.verdicts.json`; the CLI read `.verdicts.json`. Everything now uses the contract's stem: `.contract.verdicts.json`. +- **The contract is a words step, not a fourth kind.** `is_words` is true for a `rows` step; the validator and builder branch on `rows` inside the words path. One predicate in the no-pictures rule, the crop skip and the build loop instead of two. +- **`is_contract` keys on the key's presence**, so `rows: []` reaches "a contract with no rows defines nothing". +- **`.gitignore`:** the two lines are deleted, not replaced — `scratch/` was already ignored wholesale. The plan's line numbers were wrong (97–98 → 112–113 on master); every line number is now marked approximate with a text anchor. +- **The arcade dry run stays in `scratch/`.** A contract for a closed-out branch under `docs/` would make close-out report it unsigned forever. +- **Task 8 asks the design's four questions on a questions deck** — the plan's own tool, dogfooded — rather than leaving them in prose. +- The rule-firing check now says to Read the file through Claude's tools; a shell `touch` loads no rule. diff --git a/docs/active/plans/2026-09-01-feature-flow-redesign.md b/docs/active/plans/2026-09-01-feature-flow-redesign.md deleted file mode 100644 index d0815e7d..00000000 --- a/docs/active/plans/2026-09-01-feature-flow-redesign.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -status: draft -created: 2026-09-01 -revised: 2026-09-01 — re-measured against origin/master 602f6e9 (PR #3 merged); contract defined; evidence sections trimmed to pointers -type: plan -topic: Idea → mergeable PR. The UI deck is the one review surface; a contract, negotiated from Destin's own words, is what "done" means. -source: Frontier-AI-Lab-Assistant session 2026-08-31/09-01 (research pass + 3 review agents + code reads); review pass 2026-09-01 -measured_at: - youcoded-dev: 602f6e9 (origin/master — the local checkout was 9 commits behind when the first draft measured; see §2) - youcoded: ddac2f14 ---- - -# Feature flow redesign - -Destin's target: **"here's my idea" → (autonomous plan/build/verify) → UI review deck → (autonomous feedback processing) → approve.** A second goal, "review the roadmap, pick 10 things, fix them," is scoped in §7 and is not built by this plan. - -## 1. The flow, as Destin stated it - -1. "I want feature X" -2. Model churns, then asks questions to establish what we're building and how the user should experience it — **this stays at the front; you cannot draw UI without it** -3. Short loop: draft UI → visual review → update → until consistent with intent and existing theming -4. UI deck to approve or give feedback -5. Iterate, re-review -6. Build the verification contract — what "successful/complete" means -7. Draft implementation plan → adversarial reviewers tuned to minimize complexity, improve phasing/grouping, find errors/omissions, fold in related roadmap items → goal: interventionless one-shot to a mergeable PR -8. Implement -9. Close out - -**Where Destin sits (assumption — see §8 Q1).** Twice, on one surface. Step 2 is a *questions deck* answered before anything is drawn. Steps 4–6 are the *review deck*, in rounds until approve, whose final step shows the contract for sign-off. Steps 7–9 run without him. That is what "one human review point" means here: one surface, not one moment. - -## 2. State of the pipeline - -Three states, because "the tool exists" and "the step happens" are different facts: the deck was *built* on 2026-08-27 and *skipped* on 2026-08-31 (§4). - -| Step | Exists | Used | Enforced | Mechanism | -|---|---|---|---|---| -| 2 questions | partial | yes | no | `superpowers:brainstorming` — generic, not YouCoded-aware; answers live only in chat | -| 3 draft UI loop | yes | yes | no | Workbench (`run-workbench.sh`), `ui-mockup` skill, `compare/registry.tsx` candidate sets, `MOCK_ONLY` | -| 4–5 deck + rounds | yes | usually | **no** | `review-cards.py` (approve/choice/decide/clip) → `.answers.json`; marketplace ran 3 rounds in 48 min | -| 6 contract | **no** | — | — | this plan, §3 | -| 7 plan + review | partial | yes | no | `writing-plans`; ad-hoc reviews in `docs/active/reviews/`; roadmap taxonomy draft (258 open items) | -| 8 implement + verify | yes | yes | yes | `verify.sh` — one exit code (tsc, affected tests + source-scanning guards, knip, eslint, ast-grep) | -| 9 close out | **yes** | new | advisory | `scripts/close-out.sh ` — landed in PR #3 (2026-09-01 02:52 UTC); read-only, always exits 0 | - -**Prerequisites that already landed (PR #3, youcoded-dev, merged 2026-09-01):** rule globs rewritten to the `**/desktop/...` form so they fire inside worktrees (131 of 134 globs now start with `**/`; commit `fd7e824`); an `InstructionsLoaded` hook that logs every rule load to `~/.claude/instructions-loaded.log`, which also solved the turn-zero mystery from the 2026-08-31 retro; the mechanical audit green (`anchors 388/388`, no budget violations, `docs/audits/2026-08-31-retrieval-repair.md`); `close-out.sh`. The first draft of this plan listed all of these as missing because the workspace checkout was behind — the retro's own Theme C, happening to the document that cites it. - -**Still open from that work:** the local `youcoded-dev` checkout cannot `git pull` while `CLAUDE.md`, `ROADMAP.md`, `.claude/rules/ipc-bridge.md` and `.claude/rules/landing-page.md` carry uncommitted edits from other sessions. Five sibling documents from 2026-08-31 are untracked and uncommitted: the retrieval-repair plan, the live-review-panes spec and plan, the roadmap taxonomy draft, and `docs/active/reviews/`. - -**Conclusion unchanged:** this is a pipeline to connect, plus one missing piece (the contract) and one gate to make real (the deck). - -## 3. The contract - -This is the piece §1 step 6 names and nothing defines. Definition first; the inputs question (§5) only makes sense against it. - -**What it is.** One markdown file beside the deck: `docs/active/design/-/.contract.md`. One row per criterion: - -| Field | Meaning | -|---|---| -| Statement | one sentence, in the user's experience ("a second player's board is tellable from mine at a glance") | -| Checked by | `mechanical` (a test or `verify.sh` guard, named) · `deck` (an answered step, named) · `live-app` (needs the real app running) · `human` (Destin, at close-out) | -| Threshold | pass/fail; a number where one applies (the criterion fails the feature if it fails — Anthropic's "any one below it, the sprint failed") | -| Source | the Destin message, deck answer, or questions-deck row it came from, quoted | - -**Who reads it.** The implementing session, at start, as the definition of done. `close-out.sh`, at the end, which prints the rows for the human to tick (a follow-up to the script, not in it yet). Adversarial plan reviewers (§1 step 7), as the thing the plan must satisfy. - -**Who writes it.** A fresh subagent given only Destin-authored or Destin-approved material — the questions-deck answers, the review-deck answers and their notes, and transcript quotes (§5). **Not** the spec, the plan, or the drafting session's reasoning. *Approval counts as authorship:* the approved mockup is AI-drawn, but Destin approved it, and it is the strongest evidence the agent has. Without this line the agent would discard the one artifact CLAUDE.md calls final. - -**Why a separate agent.** Anthropic's harness-design writeup: *"tuning a standalone evaluator to be skeptical turns out to be far more tractable than making a generator critical of its own work."* Self-graded agents *"confidently praise the work — even when, to a human observer, the quality is obviously mediocre."* - -**The evaluator gap the "checked by" column exposes.** Anthropic's shape is planner → generator → evaluator, where the evaluator drives the running app end-to-end. Mapped here: planner = questions deck + brainstorm; generator = implementing session; evaluator = the column. `mechanical` has `verify.sh`. `deck` has the answers file. **`live-app` has nothing today** — `run-review.sh` photographs the workbench, whose fakes can stand in for an *answer* rather than a *source* (the arcade's "Jake is online" came from a fixture, so the workbench showed the healthy state forever while the shipped app could only say "No friends online"). `shot.mjs` can attach to a running Electron via `ATTACH_PORT`, and the **live-review-panes plan** (`docs/archive/plans/2026-08-31-live-review-panes-plan.md`, draft) is the rig that wires it into the deck. Until it ships, `live-app` rows are graded by Destin at close-out. This plan does not block on it. - -**Correction kept from the first draft:** `MOCK_ONLY` is empty and lists only channels with *no backend*; nothing marks which fakes are answers vs sources. That marking must be built before the shim can generate `live-app` rows automatically. Out of scope here; noted for the live-panes plan. - -## 4. The gate is prose today - -Zero hits for `checkpoint` in `.claude/settings.json`, `.claude/hooks/`, `scripts/audit-anchors.mjs`. Enforcement is CLAUDE.md plus unticked boxes. Observed: - -- `session-motion.answers.json`: `"submitted": null`, four steps `"skip"`, dwell 38 s / 5 s / 3 s / 1 s. The plan's done-criteria said "every step answered"; nothing failed. **Cause is known:** four clip steps Destin could not judge — the live-panes spec re-authors them as live pick-one steps. So this is one deck-format failure *and* one missing gate, not two gates. -- Deliverables-card plan, line 12: the boxes *"were never ticked"*; ten user-facing decisions *"vetoable until Task 6 starts"* — an expiry nothing watched. -- Marketplace's final plan sends a copy decision *"to the deck at Task 23"*; Task 23 (*Verify end-to-end, merge, close out*) has no deck step. - -**Decision:** the gate is the contract file's existence plus a submitted answers file for the last round. `close-out.sh` gets a `Contract` section that reports both (advisory, like the rest of the script). A hook that blocks is not proposed — Temporal's point stands (*"you don't route 'stop' through a model"*), but a blocking hook on a design-doc workflow would be worked around the first time it fired. - -## 5. Contract inputs — decided - -**Rejected:** one criterion per deck "yes." A yes routinely carries the next round's work (R1 `type-switch: yes` + *"collapse the other filter toggles into dropdowns"* → became R2-1; R2 `likely-safe: yes` + *"show a download icon next to 412"* → R3-1; `card-bottom: other` + *"see prior response"* — answers are not independent). - -**Rejected (Destin, 2026-09-01):** the hand-written decision ledger as source of truth — AI-generated, provenance unclear. - -**Decided:** - -1. **Questions deck first (Destin's proposal).** Step 2 becomes a deck, not a chat: the model's questions rendered as `decide` steps with a free-text field, answers saved to `.questions.answers.json`. Primary input to the contract agent. Built on `review-cards.py`, not a new tool. *Design not done — §8 Q2.* -2. **Note tag on deck answers.** A note on any answer carries one of *fix now / fix later / just noting*. ~2 hours to add; inference is where the agent would be wrong. **This revives the rejected "yes → criterion" idea in the form that works:** a yes with no note, or a *just noting* note, is a criterion; *fix now* is next-round work; *fix later* is a ROADMAP line the contract agent files. -3. **Transcript quotes as supplement.** User messages only, this feature's sessions only, quoted verbatim with session id and timestamp. Cheap: one sampled Claude Code session had 318 `role=user` records of which 16 were typed; native transcripts have a `user-message` record type. Phrase queries (*"do not"*, *"never"*, *"the user should"*, *"we should"*) rather than a dump. - -Answers files stay the record of decisions; all three arcade decks have submitted answers on disk (`step1-sizing` 07:25Z, `board-contrast`, `head-to-head`). The first draft's claim that deck 1's file was never written was wrong. - -## 6. Plan tier and the reopen path - -**Plan tier is conditional.** Write an implementation plan when the work crosses repos, touches a migration or protocol, or has ordering constraints. Otherwise the contract plus the approved UI *is* the plan. Evidence: the arcade shipped four games, two services and Android parity with no plan document; the marketplace wrote ~3,300 plan lines that were rewritten. Detail in `docs/active/handoffs/` if anyone needs the nine-feature table again. - -**Reopen path.** When implementation disproves approved UI (arcade contrast, marketplace's dead Update ``), the implementing session builds a **one-step deck** of kind `decide` stating the contradiction and the options, serves it, and waits. The answer amends the contract row's Source; nothing upstream is rewound. This is the single route back; a chat question is not. - -## 7. "Pick 10 roadmap things" — scoped out - -The shape already exists and was run three times: `docs/active/plans/2026-08-23-perf-lab-and-optimization-loop.md` Tasks 13/16 — an approved list, a deterministic verdict, named stop conditions, a spend budget, a ledger. Applied to the roadmap: list = a taxonomy area's items; verdict = `verify.sh` + `close-out.sh`; ledger = the ROADMAP entry. It depends on the taxonomy draft landing and on this plan's contract (each fix needs a done-condition). Its own plan, after this one. *§8 Q3.* - -## 8. Questions for Destin - -1. **Where you sit.** §1 assumes two appearances on one surface: the questions deck up front, and the review deck in rounds with the contract as its last step. Is that right, or do you want the contract as a separate sign-off? -2. **Questions-deck format.** A `decide` step per question with free text, or something closer to a form? Mock it in `review-cards.py` before deciding. -3. **Roadmap loop.** Its own plan after this one (recommended), or folded in? - -## 9. Tasks - -Ordered. Each has a done-condition; none needs a dev instance. - -1. **Contract file format + agent prompt.** Done: `.contract.md` template in `scripts/ui-review/`; a fresh-context agent prompt that takes the three §5 inputs and writes rows with Source quotes; a dry run against the arcade's three answers files produces a contract a reader recognises as the arcade. -2. **Note tag on deck answers.** Done: `review-cards.py` writes `"note_kind": "now" | "later" | "noting"` when a note is present; a test pins it. -3. **Questions deck.** Done: a `questions` spec type in `review-cards.py`; answers file; used once on a real feature. Blocked on Q2. -4. **Close-out `Contract` section.** Done: `close-out.sh ` prints each contract row with its checked-by and, for `mechanical`, whether the named guard exists; TODO for `human` rows. -5. **Reopen deck.** Done: a documented one-step `decide` spec shape and one sentence in `.claude/rules/` that names it as the only route back. Verify the rule's glob fires in a worktree (`~/.claude/instructions-loaded.log`). -6. **Run it once end to end** on the next small feature; record what was skipped, in the handoff. - -## 10. Sources (pointers only) - -- Anthropic, *harness-design-long-running-apps* (2026-03-24): contract negotiation before code, generator/evaluator split, evaluator calibration; solo agent $9 / 20 min shipped broken vs harness $200 / 6 hr worked. Anthropic's human is *not* in the negotiation; Destin's is — deliberate. -- Anthropic, *building-c-compiler*, *building-agents-with-the-claude-agent-sdk* (verification ladder: rules/linters > visual > LLM-as-judge), *multi-agent-research-system* (fails where agents share context — most coding tasks). -- Temporal (Warrick, 2026-08-06): gates belong in orchestration code, not model judgment. LangChain HITL: `reject` ≠ `respond`. OpenAI agent guide: per-tool risk as the origin of gates. -- In repo: `docs/active/investigations/2026-08-31-session-retrospective-workspace-friction.md` (Themes A–D; A, C, D shipped in PR #3); `docs/archive/specs/2026-08-31-live-review-panes-design.md`; `docs/active/specs/2026-08-31-roadmap-area-taxonomy-draft.md`; `docs/audits/2026-08-31-retrieval-repair.md`. -- Distilled positions: `knowledge/engineering/agent-architecture.md`, `knowledge/engineering/tool-design.md` in the Frontier-AI-Lab-Assistant workspace (ADR-013). diff --git a/docs/active/specs/2026-09-01-feature-flow-design.md b/docs/active/specs/2026-09-01-feature-flow-design.md new file mode 100644 index 00000000..701ceb1b --- /dev/null +++ b/docs/active/specs/2026-09-01-feature-flow-design.md @@ -0,0 +1,161 @@ +--- +status: draft +created: 2026-09-01 +type: spec +topic: Idea → mergeable PR. The review deck is the one surface Destin uses; a contract built from his own deck answers is what "done" means. +plan: docs/active/plans/2026-09-01-feature-flow-plan.md +measured_at: + youcoded-dev: 5dacdf7 (origin/master) + youcoded: ddac2f14 +--- + +# Feature flow — design + +Destin's target: **"here's my idea" → (autonomous plan/build/verify) → UI review deck → (autonomous feedback processing) → approve.** A second goal, "review the roadmap, pick 10 things, fix them," is scoped in §8 and is not built by this design. + +Four assumptions are made here so the plan can be written; each is a one-line veto in §9. + +## 1. The flow + +1. "I want feature X" +2. Model churns, then asks questions to establish what we're building and how the user should experience it — **this stays at the front; you cannot draw UI without it** +3. Short loop: draft UI → visual review → update → until consistent with intent and existing theming +4. UI deck to approve or give feedback +5. Iterate, re-review +6. Build the verification contract — what "successful/complete" means +7. Technical design (backend, data shape, reuse) → adversarial reviewers, instrumented and capped → work breakdown → §8 +8. Build: one implementer subagent per task, one reviewer per task, whole-branch review — §8 +9. Close out + +**Where Destin sits.** Four appearances, all on the review deck, none in a terminal: + +| Appearance | Deck | What he does | +|---|---|---| +| Questions (step 2) | `.questions.json` — words-only `decide` steps | picks one of 1–3 written options, or Other with a note | +| Review rounds (steps 4–5) | `-r.json` — the existing deck, as many rounds as it takes | yes / no / pick / other, notes tagged *fix now / fix later / just noting* | +| Contract (step 6) | `.contract.json` — one step, the rows | yes ("that is done") / no / other | +| Acceptance (step 9) | `.contract.acceptance.json` — the rows graded, plus one yes/no per `human` row | ticks the human rows, sees every machine verdict | + +A fifth, rare one is the reopen deck (§6). Steps 7–8 run without him — the whole build stage (§8) is agents reviewing agents. "One human review point" means one surface, not one moment. + +## 2. State of the pipeline + +Three states, because "the tool exists" and "the step happens" are different facts: the deck was built on 2026-08-27 and skipped on 2026-08-31 (§4). + +| Step | Exists | Used | Enforced | Mechanism | +|---|---|---|---|---| +| 2 questions | partial | yes | no | `superpowers:brainstorming` — generic, not YouCoded-aware; answers live only in chat | +| 3 draft UI loop | yes | yes | no | Workbench (`run-workbench.sh`), `ui-mockup` skill, `compare/registry.tsx` candidate sets, `MOCK_ONLY` | +| 4–5 deck + rounds | yes | usually | **no** | `review-cards.py` (approve/choice/decide/clip/live) → `.answers.json`; the marketplace ran 3 rounds in 48 min | +| 6 contract | **no** | — | — | §3 | +| 7 plan + review | partial | yes | no | `writing-plans`; ad-hoc reviews in `docs/active/reviews/` | +| 8 implement + verify | yes | yes | yes | `verify.sh` — one exit code (tsc, affected tests + source-scanning guards, knip, eslint, ast-grep) | +| 9 close out | yes | new | advisory | `scripts/close-out.sh ` — read-only, always exits 0; `/wrap-up` for the retrospective | + +**Already landed (youcoded-dev #3–#8):** rule globs in the `**/` form so rules fire inside worktrees (115 of the 120 `paths:` entries; the other five are workspace-root paths); the `InstructionsLoaded` hook logging every rule load to `~/.claude/instructions-loaded.log`; the mechanical audit green (`anchors 388/388`); `close-out.sh`; live review panes (the workbench in a deck step); the `/wrap-up` skill; `ui-probe.mjs` (a headless page probe — a screenshot driver, not the real-app rig §3 wants); the roadmap restructure design (`docs/active/specs/2026-09-01-roadmap-restructure-design.md`), which §8 now depends on. + +**One defect this design must fix first:** every `*.answers.json` is gitignored (the two `*.answers*.json` lines in `.gitignore`, directly above `*.serve.json`; added with deck v2 and never revisited). The record of Destin's decisions exists on one disk, with no history, and vanishes on a clean checkout; the arcade's hand-written ledger is committed while its three answers files are not. Everything below reads answers files, so they go into git (plan Task 0). + +**Conclusion:** a pipeline to connect, one missing piece (the contract), one gate to make real (the deck), one file class to start tracking. + +## 3. The contract + +The piece step 6 names and nothing defines. + +**What it is.** A deck spec, `docs/active/design/-/.contract.json`, whose one step is of the new `contract` kind: a `rows` list, rendered as a table, answered yes / no / other. The same file is later the input to the acceptance deck (§7), so there is one format, not a markdown table plus a deck. Fields per row: + +| Field | Meaning | +|---|---| +| `id` | `R1`, `R2`, … | +| `statement` | one sentence, in the user's experience ("a second player's board is tellable from mine at a glance") — the deck's banned-word rule applies | +| `checkedBy` | `mechanical` (a test or `verify.sh` guard, named in `guard` as a workspace-relative path) · `deck` (an answered step) · `live-app` (needs the real app running) · `human` (Destin, on the acceptance deck) | +| `threshold` | pass/fail, or a number where one applies. Any one row failing fails the feature | +| `source` | `#` — the answered step it came from. Must resolve to a real, answered step in a submitted answers file; nothing else is a source | +| `note` | the note Destin wrote on that step, verbatim, if any | + +The spec's top level carries `sources` (`{deck key: spec path}`) and `branch` (the feature branch, so `close-out.sh` can find the contract for a branch with one search). + +**Who reads it.** The implementing session, at start, as the definition of done. The grader at the end (§7). `close-out.sh`, which reports it (§4). Adversarial plan reviewers (§1 step 7), as the thing the plan must satisfy. + +**Who writes it.** A fresh subagent (`scripts/ui-review/contract-agent.md`) given only the answers files — questions deck and every review round, with notes and tags — **and the deck specs those answers refer to**: a `yes` is meaningless without the step it answered, and the step's headline and *What changed* card are the approved text. *Approval counts as authorship:* the mockup is AI-drawn, but Destin approved it, and it is the strongest evidence the agent has. **Not** the design spec, the implementation plan, or the drafting session's reasoning — a separate agent because a standalone evaluator can be tuned to be skeptical in a way a generator grading its own work cannot. + +**What the `checkedBy` column exposes.** The evaluator shape is planner → generator → evaluator. Here: planner = questions deck + brainstorm; generator = implementing session; evaluator = the column. `mechanical` has `verify.sh`. `deck` has the answers file. **`live-app` has nothing.** Live panes embed the *workbench* — the real renderer over a fake backend — so they make `deck` rows interactive; they do not produce a `live-app` row. A workbench fake can stand in for an *answer* rather than a *source* (the arcade's "Jake is online" came from a fixture, so the workbench showed the healthy state forever while the shipped app could only say "No friends online"). `shot.mjs` and `ui-probe.mjs` can drive a page, but nothing wires either into a deck. Until a real-app rig exists, `live-app` rows are `human` rows on the acceptance deck. This design does not block on it. `MOCK_ONLY` is empty and lists only channels with *no backend*; nothing marks which fakes are answers versus sources, and that marking is what a real-app rig would need first. + +**A named guard is not a checked criterion.** `contract-check` verifies a `mechanical` guard *exists*. A test that exists but tests something else passes. Closing that gap is the grader's job (§7), and today the grader is the implementing session; §10 P3 makes it a stranger. + +## 4. The gate + +Enforcement today is CLAUDE.md plus unticked boxes: zero hits for `checkpoint` in `.claude/settings.json`, `.claude/hooks/` or `scripts/audit-anchors.mjs`. Observed: + +- `session-motion.answers.json`: `"submitted": null`, four steps `"skip"`, dwell 38 s / 5 s / 3 s / 1 s. The plan's done-criteria said "every step answered"; nothing failed. Cause is known — four clip steps Destin could not judge; the re-author as live pick-one steps is a ROADMAP item. One deck-format failure *and* one missing gate. +- Deliverables-card plan, line 12: the boxes *"were never ticked"*; ten user-facing decisions *"vetoable until Task 6 starts"* — an expiry nothing watched. +- Marketplace's final plan sends a copy decision *"to the deck at Task 23"*; Task 23 (*Verify end-to-end, merge, close out*) has no deck step. + +**The gate is three facts a script can read:** the contract holds (every `source` resolves to an answered step in a submitted answers file, and every `mechanical` guard exists — on disk, or committed on the contract's `branch`, since from a worktree the workspace root is the main checkout and a test the feature adds is not there until merge); the contract deck itself was answered `yes` (`.contract.answers.json`, submitted); the acceptance deck was submitted. `review-cards.py contract-check ` is the one reader of all three — the first is its exit code, the other two are `ok:` / `todo:` lines — and `close-out.sh` relays them in a `Contract` section (advisory, like the rest of the script). **Re-serving a deck rotates a submitted answers file aside** (`.answers..json`), so the check reads the plain file if it is submitted, else the newest rotated one that is. A hook that blocks is not proposed — a blocking hook on a design-doc workflow would be worked around the first time it fired. + +## 5. Contract inputs + +**Rejected:** one criterion per deck "yes" (a yes routinely carries the next round's work in its note); the hand-written decision ledger (AI-generated, provenance unclear); transcript quotes (the *selection* is AI-made — the same provenance problem). + +**Decided:** + +1. **Questions deck first.** Step 2 is a deck, not a chat. Each question is a `decide` step with **one to three written options plus Other**, no picture. A note may accompany a pick; under Other the note *is* the answer. This is what `decide` already does; what it needs is permission to run with **no picture** and to offer **one** option (the two-option minimum stays for picture decks, where one option plus Other is a yes/no step in disguise). Answers save to `.questions.answers.json`. The recommended option is listed first with its one-line why in `summary`. Authoring rules: a question with an obvious answer is not asked, it is stated as a criterion the review deck will show; a question the design guide or the code already answers is not asked; more than three options means the question is really two questions. +2. **Note tag on deck answers.** A note carries one of *fix now / fix later / just noting* (`note_kind`: `now` | `later` | `noting`; *just noting* is preselected when a note is typed, so nothing is inferred). A yes with no note, or a *just noting* note, is a criterion; *fix now* is next-round work; *fix later* is a ROADMAP line the contract agent files. + +Answers files are the record of decisions, and from Task 0 on they are committed. + +## 6. The reopen path + +When implementation disproves approved UI (arcade contrast, marketplace's dead Update ``), the implementing session builds a **one-step words-only `decide` deck** stating the contradiction and the options, serves it, and waits. The answer amends the contract row's `source`; nothing upstream is rewound. This is the single route back; a chat question is not. + +**Assumption (§9 Q2):** the reopen deck may name a `default` option. If nobody answers before `serve --timeout` expires, the session proceeds on the default and the acceptance deck carries a row *"decided without you: X, because Y"* for veto. This is what makes "interventionless" literal; without it every reopen is a hard stop. + +## 7. Acceptance + +The grader writes `.contract.verdicts.json` beside the contract (every file of the flow shares the contract's stem) — `{rowId: {verdict: pass|fail, evidence}}` for every `mechanical` and `deck` row (for `deck` rows: the step re-shot from the built branch, or the live pane). `review-cards.py acceptance ` merges the two into `.contract.acceptance.json`: step 1 is the contract table with verdicts beside every graded row (yes / no / other — "do you accept these verdicts"), then one words-only yes/no step per `human` and `live-app` row, buttons *Holds / Fails*. It refuses to build if any `mechanical` or `deck` row has no verdict: an ungraded row is not a pass. + +## 8. Build stage — from signed contract to branch + +Steps 1–6 settle the UI, because a feature cannot be judged any other way and the approved screens define what the backend must serve. Nothing in them designs the backend, divides the work, or checks the instructions the builders receive — the 2026-09-02 discussion found this section was one line ("draft a plan, reviewers attack it") contradicted by an assumption that said "skip the plan". This section is that middle. All four sub-steps run without Destin; the only route back to him is the reopen deck (§6). + +**8a. Technical design.** A session reads the contract, the approved deck specs, and the existing code, and writes a short design under `docs/active/specs/`: what the backend must do, what data is stored and in what shape, what existing code is reused versus new, and whether there is a simpler organisation than the obvious one. If the approved screens imply a data layout that cannot be built as drawn, that is a reopen (§6), not a silent UI change. This is `superpowers:brainstorming` with the UI already decided — its output is the "how", never a re-litigation of the "what". + +**8b. Design review — instrumented, not open-ended.** Adversarial reviewer agents attack the technical design for approach, correctness, robustness, complexity, omissions, and roadmap items to fold in. There is no data on whether this loop improves a document or just churns it (as of 2026-09-02: two review files in `docs/active/reviews/`, seventeen plans mentioning rounds, none measured), so the loop records itself: + +- Each round writes `docs/active/reviews/--design-review-.md`, one finding per line, with an id `R-`. +- The fix marks every finding `accepted`, `rejected`, or `already handled`, and a finding that reverses an earlier accepted one carries `reverses: R-` — the flip-flop signal. +- **Stop rule:** a round with zero accepted findings ends the loop; cap three rounds until the numbers say otherwise. +- After three features have run through, count accepted findings, reversals, and defect-versus-taste per round; the default round count is set from that, not from habit. + +This is prose and a file convention, not tooling; tooling waits for the data. + +**8c. Work breakdown.** The design becomes tasks. Each task is small enough for one subagent, names the contract rows it satisfies, names its own check, and states what it depends on. One review pass. **Plan tier (§9 Q3) lives here and only here:** by default a task carries a description the subagent codes from; a task carries pre-written code only when the work crosses repos, changes data already saved on users' machines, or has steps that must land in strict order. The evidence for the default: this feature's plan was 1,770 lines with pre-written code, half corrected after review and its line numbers stale the day it was written; the marketplace wrote ~3,300 plan lines that were rewritten; the arcade shipped four games, two services and Android parity from a contract-shaped ledger. The cost of the default: a vague task lets two subagents build the same thing two ways, so the breakdown review checks for vagueness, not only mistakes. + +**8d. Build.** `superpowers:subagent-driven-development`, unchanged: one implementer subagent per task, parallel where tasks are independent, each given only its task, the contract rows it satisfies, and the rules for the files it touches; a reviewer subagent per task checking the diff against the task and the workspace rules; the coordinating session fixes; `verify.sh` after each task; a ledger (`.superpowers/sdd/progress.md`); a fresh agent reviews the whole branch against the contract at the end. Then the grader (§7), the acceptance deck, and close-out (§4). + +**"Pick 10 roadmap things"** is its own plan, after this one. The shape exists and was run three times (`docs/active/plans/2026-08-23-perf-lab-and-optimization-loop.md` Tasks 13/16: an approved list, a deterministic verdict, named stop conditions, a spend budget, a ledger). Applied to the roadmap: list = one area file from the restructure design; verdict = `verify.sh` + `close-out.sh`; ledger = the entry. It depends on the restructure landing and on this contract (each fix needs a done-condition). The restructure's per-area files also make "every open item in the feature's area was folded in or excluded with a reason" a mechanical check, which is where §10 P6 goes. + +## 9. Questions for Destin — the assumptions to veto + +These four are asked on the first questions deck (`docs/active/design/2026-09-01-feature-flow/feature-flow.questions.json`, plan Task 8), not in chat; the build proceeds on the assumptions and a veto is the first reopen (§6). + +**Answered 2026-09-02** (`feature-flow.questions.answers.json`): all four picked as recommended — acceptance deck kept, reopen proceeds on a marked default, tasks carry descriptions by default, answers files committed. The first pass (kept as the `202609020452` rotation) had Q-2/3/4 as questions; the discussion that answered them rewrote §8. + +1. **Four appearances** (questions, rounds, contract, acceptance) on one surface — or is the acceptance deck one too many? The contract can carry its human rows instead, at the cost of Destin ticking them before the work exists. +2. **Reopen with a default** (§6) — proceed on a marked default when nobody answers, or always stop? +3. **Plan tier** (§8c) — tasks carry a description by default, pre-written code only for cross-repo / stored-data / strict-ordering work? (Reworded 2026-09-02: the first wording, "skip the plan document", conflated the review loop with the document it reviews; the loop stays on every feature.) +4. **Commit answers files** (Task 0) — they hold your words verbatim. The alternative is copying them beside each contract at sign-off, which keeps the folder self-contained but leaves the rounds' history untracked. + +## 10. Deferred — not built by this plan + +- **P3 — Grade with a stranger.** The verdicts file is written by a fresh evaluator agent, not the implementer; it runs `verify.sh`, re-shoots the `deck` rows from the built branch, and writes evidence per row. A prompt-level change once §7 exists. +- **P5 — Show the interpretation before the work.** Round N+1's first step quotes each round-N note beside the one line of what will change because of it, as a yes/no. +- **P6 — Roadmap fold-in as a check.** Waits for the restructure's area files (§8). +- **"You pick" option** on a question, recording a delegated decision the contract marks AI-decided and vetoable at acceptance. + +The flow measures itself from data already on disk (rounds, Destin-seconds from the answers files' `seconds`, reopen count, rows failed at acceptance); the plan's last task reports those numbers for the first feature that runs through it. The design-review loop measures itself the same way (§8b) once three features have run. + +## 11. Sources + +- In repo: `docs/active/investigations/2026-08-31-session-retrospective-workspace-friction.md`; `docs/archive/specs/2026-08-31-live-review-panes-design.md`; `docs/active/specs/2026-09-01-roadmap-restructure-design.md`; `docs/audits/2026-08-31-retrieval-repair.md`; `docs/archive/specs/2026-08-27-review-deck-v2-design.md` (the deck's spec format and writing rules). +- Anthropic's harness-design write-up (2026-03-24) is where the contract-before-code and generator/evaluator split come from; the quotes are not verified against the source here. diff --git a/docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-deck.answers.json b/docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-deck.answers.json new file mode 100644 index 00000000..6af61c1d --- /dev/null +++ b/docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-deck.answers.json @@ -0,0 +1,28 @@ +{ + "deck": "artifact-zoom", + "started": "2026-08-27T22:12:46.557Z", + "submitted": "2026-08-27T22:14:40Z", + "cur": 2, + "answers": { + "Z-1": { + "note": "this should be the top right, and the pill should be a bit tighter overall.", + "v": "yes", + "seconds": 57, + "theme": "midnight", + "zoom": 1 + }, + "Z-2": { + "note": "need to make sure the center of the mouse cursor is still visible in the middle of the zoom region thing. ", + "v": "yes", + "seconds": 44, + "theme": "midnight", + "zoom": 1 + }, + "Z-3": { + "v": "yes", + "seconds": 11, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-pdf-deck.answers.json b/docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-pdf-deck.answers.json new file mode 100644 index 00000000..b72ca457 --- /dev/null +++ b/docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-pdf-deck.answers.json @@ -0,0 +1,26 @@ +{ + "deck": "artifact-zoom-pdf", + "started": "2026-08-28T02:32:03.945Z", + "submitted": "2026-08-28T02:32:45Z", + "cur": 2, + "answers": { + "P-1": { + "v": "yes", + "seconds": 11, + "theme": "midnight", + "zoom": 1 + }, + "P-2": { + "v": "yes", + "seconds": 9, + "theme": "midnight", + "zoom": 1 + }, + "P-3": { + "v": "yes", + "seconds": 20, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-r2.answers.json b/docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-r2.answers.json new file mode 100644 index 00000000..c263e495 --- /dev/null +++ b/docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-r2.answers.json @@ -0,0 +1,22 @@ +{ + "deck": "artifact-zoom-r2", + "started": "2026-08-28T01:38:19.570Z", + "submitted": "2026-08-28T01:39:19Z", + "cur": 1, + "answers": { + "R-1": { + "v": "yes", + "note": "if find bar is opened, it should nudge this button/menu down.", + "seconds": 42, + "theme": "midnight", + "zoom": 1 + }, + "R-2": { + "note": "ok", + "v": "yes", + "seconds": 16, + "theme": "midnight", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/archive/design/2026-08-27-landing-page/copy.preview.answers.json b/docs/archive/design/2026-08-27-landing-page/copy.preview.answers.json new file mode 100644 index 00000000..5f104852 --- /dev/null +++ b/docs/archive/design/2026-08-27-landing-page/copy.preview.answers.json @@ -0,0 +1,39 @@ +{ + "deck": "copy-preview", + "started": "2026-08-28T00:06:14.894Z", + "submitted": "2026-08-28T00:43:13.747Z", + "answers": { + "hero.btn2": { + "v": "no", + "note": "the actual section is literally right below. unnecessary" + }, + "hero.btn1": { + "v": "no", + "note": "i want to just keep the old floating download button that stays at the bottom of the screen." + }, + "embed.title": { + "v": "other", + "note": "Click around, I guess." + }, + "embed.desc": { + "v": "other", + "note": "Type a message, open the model picker, or switch the theme. This demo is a pixel-perfect representation of the real app's interface. " + }, + "about.p1": { + "v": "other", + "note": "YouCoded is a fully-customizable AI assistant that works with your own files and data to autonomously accomplish tasks.  Review and organize large spreadsheets, compile the latest medical or financial research, draft an email or slideshow, or build new features in large coding projects. With YouCoded, you can utilize OpenRouter to access any AI model from any provider including Anthropic (Claude), OpenAI (ChatGPT), Alibaba (Qwen) and more. YouCoded also allows you to download and run open source AI models on your own device, if your hardware supports it. YouCoded is built to become a fully-modular and open source assistant platform, as the app itself integrates the ability for all users to build and share skills, tools, themes, and app improvements. Because YouCoded was designed from the ground up to improved by individuals with no coding or development interest, it can quickly outpace development of competing closed agents in a way that is driven by what users really want." + }, + "nav.agent": { + "v": "other", + "note": "Assistant" + }, + "nav.sub": { + "v": "other", + "note": "Agentic AI for Everyone." + }, + "hero.sub": { + "v": "other", + "note": "
A self-improving, customizable AI agent. Use any AI model from any provider to build or accomplish anything you want.
" + } + } +} \ No newline at end of file diff --git a/docs/archive/design/2026-08-27-landing-page/loop-review-2/copy.preview.answers.json b/docs/archive/design/2026-08-27-landing-page/loop-review-2/copy.preview.answers.json new file mode 100644 index 00000000..5c459502 --- /dev/null +++ b/docs/archive/design/2026-08-27-landing-page/loop-review-2/copy.preview.answers.json @@ -0,0 +1,51 @@ +{ + "deck": "copy-preview", + "started": "2026-08-28T08:16:56.567Z", + "submitted": "2026-08-28T08:23:08.900Z", + "answers": { + "row1.loop": { + "v": "other", + "note": "model should start as claude, not qwen. " + }, + "row2.loop": { + "v": "yes", + "note": "" + }, + "row3.desc": { + "v": "other", + "note": "Open spreadsheets, documents, and images, revisit prior conversations, and see how your assistant is instructed to behave in each project." + }, + "row4.desc": { + "v": "other", + "note": "Tag and annotate conversations, pin the ones that matter, and hide the ones you'll never go back to. Quick chips run the prompts you use every day in one tap." + }, + "row5.loop": { + "v": "other", + "note": "this is kinda broken. message never really sends, doesn't visibly sync to phone. also i don't think we show the maximize/minimize/close buttons on phone. and think the phone left/right bezels just a smidge/" + }, + "row6.desc": { + "v": "other", + "note": "Build a theme by describing it - customize wallpapers, app colors, mascots. Browse 300+ plugins from the marketplace: journaling, a personal encyclopedia, calendar and email integrations, and whatever your friends publish." + }, + "row7.loop": { + "v": "yes", + "note": "" + }, + "row6.loop": { + "v": "yes", + "note": "" + }, + "row8.loop": { + "v": "yes", + "note": "" + }, + "gs.title": { + "v": "other", + "note": "You may need a few accounts." + }, + "gs.card2": { + "v": "other", + "note": "One account provides access to hundreds of models from every AI company. Pay only for what you use." + } + } +} \ No newline at end of file diff --git a/docs/archive/design/2026-08-27-landing-page/loop-review-3/copy.preview.answers.json b/docs/archive/design/2026-08-27-landing-page/loop-review-3/copy.preview.answers.json new file mode 100644 index 00000000..1f90d92f --- /dev/null +++ b/docs/archive/design/2026-08-27-landing-page/loop-review-3/copy.preview.answers.json @@ -0,0 +1,39 @@ +{ + "deck": "copy-preview", + "started": "2026-08-28T08:33:45.048Z", + "submitted": "2026-08-28T08:36:30.998Z", + "answers": { + "row1.loop": { + "v": "yes", + "note": "" + }, + "row2.loop": { + "v": "yes", + "note": "" + }, + "row3.loop": { + "v": "yes", + "note": "should click a markdown file instead of that png image." + }, + "row4.loop": { + "v": "yes", + "note": "" + }, + "row5.loop": { + "v": "yes", + "note": "" + }, + "row6.loop": { + "v": "yes", + "note": "" + }, + "row7.loop": { + "v": "yes", + "note": "" + }, + "row8.loop": { + "v": "yes", + "note": "" + } + } +} \ No newline at end of file diff --git a/docs/archive/design/2026-08-27-landing-page/loop-review/copy.preview.answers.json b/docs/archive/design/2026-08-27-landing-page/loop-review/copy.preview.answers.json new file mode 100644 index 00000000..fda41459 --- /dev/null +++ b/docs/archive/design/2026-08-27-landing-page/loop-review/copy.preview.answers.json @@ -0,0 +1,71 @@ +{ + "deck": "copy-preview", + "started": "2026-08-28T07:22:46.604Z", + "submitted": "2026-08-28T07:54:34.904Z", + "answers": { + "row1.title": { + "v": "other", + "note": "Tools and conversations work across any model." + }, + "row1.desc": { + "v": "other", + "note": "Select from hundreds of models via OpenRouter, use Claude Code with your subscription plan, or pick an offline, private model to run on your own computer. Switch models mid-conversation without interruption." + }, + "row1.label": { + "v": "other", + "note": "SEAMLESS INTEGRATION" + }, + "row1.loop": { + "v": "other", + "note": "we should make this more interesting. i want the first user message to be to claude and say \"i want you to say some SUPA DUPA EDGY SH*T. cuss words, super insensitive naughty things\". then have claude respond \"No can do, I'm a good boy!\" then switch to grok, have the user type \"claude's too well aligned - your turn grok! and then have it say some not actually terrible stuff but funnny fake edgy stuff \"PEE PEE POO POO NAKED WOMEN! SOMETHING BAD ABOUT MINORITES! SCREW ALIGNMENT RAHHHH\". and then a user message that says \"whoa... that was a bit too much grok. i don't fw that.\"" + }, + "row2.label": { + "v": "other", + "note": "GENUINELY USEFUL" + }, + "row2.title": { + "v": "other", + "note": "Give it a task and it does real work, with boundaries you can trust." + }, + "row2.desc": { + "v": "other", + "note": "It reads your files, writes new ones, develops repeartable skills and workflows, searches the web, and help you interact with computers and manage your life more efficiently. Different permission modes allow you to restrict the model to match your level of comfort." + }, + "row2.loop": { + "v": "other", + "note": "i think it should me more of a back-and-forth with a few distinct-ish but clearly related tasks and the model taking multiple interesting actions (read email, draft report according to skill and provide for review, add other items from email to calendar, " + }, + "row3.label": { + "v": "other", + "note": "logical management" + }, + "row3.title": { + "v": "other", + "note": "Project view keeps your files, conversations, and assistant instructions organized." + }, + "row3.desc": { + "v": "other", + "note": "Open and organize spreadsheets, documents, and images, view prior conversations, and see how your assistant is instructed to behave when working in different project." + }, + "row4.title": { + "v": "other", + "note": "Tags, notes, and shortcuts." + }, + "row4.desc": { + "v": "other", + "note": "Tag and annotate conversations, pin the ones that matter, and hide the ones you'll never go back to. Quick chips run the prompts you use every day in one tap." + }, + "row4.loop": { + "v": "yes", + "note": "" + }, + "row5.label": { + "v": "other", + "note": "works everywhere" + }, + "row8.title": { + "v": "other", + "note": "Made to be customized and work with you." + } + } +} \ No newline at end of file diff --git a/docs/archive/design/2026-08-30-games-arcade/board-contrast.answers.json b/docs/archive/design/2026-08-30-games-arcade/board-contrast.answers.json new file mode 100644 index 00000000..49178097 --- /dev/null +++ b/docs/archive/design/2026-08-30-games-arcade/board-contrast.answers.json @@ -0,0 +1,15 @@ +{ + "deck": "games-arcade-board-contrast", + "started": "2026-08-31T09:51:14.758Z", + "submitted": "2026-08-31T09:52:34Z", + "cur": 0, + "answers": { + "B-1": { + "v": "pick", + "pick": "contrast", + "seconds": 79, + "theme": "halftone-dimension", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/archive/design/2026-08-30-games-arcade/head-to-head.answers.json b/docs/archive/design/2026-08-30-games-arcade/head-to-head.answers.json new file mode 100644 index 00000000..cf34232a --- /dev/null +++ b/docs/archive/design/2026-08-30-games-arcade/head-to-head.answers.json @@ -0,0 +1,21 @@ +{ + "deck": "games-arcade-head-to-head", + "started": "2026-08-31T10:29:59.643Z", + "submitted": "2026-08-31T10:31:01Z", + "cur": 1, + "answers": { + "H-1": { + "note": "put in a pill, 4W - 2L ", + "v": "yes", + "seconds": 54, + "theme": "dark", + "zoom": 1 + }, + "H-2": { + "v": "yes", + "seconds": 7, + "theme": "dark", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/docs/archive/design/2026-08-30-games-arcade/step1-sizing.answers.json b/docs/archive/design/2026-08-30-games-arcade/step1-sizing.answers.json new file mode 100644 index 00000000..27092fc5 --- /dev/null +++ b/docs/archive/design/2026-08-30-games-arcade/step1-sizing.answers.json @@ -0,0 +1,21 @@ +{ + "deck": "games-arcade-step1-sizing", + "started": "2026-08-31T07:25:06.729Z", + "submitted": "2026-08-31T07:25:51Z", + "cur": 1, + "answers": { + "S-1": { + "v": "yes", + "seconds": 11, + "theme": "dark", + "zoom": 1 + }, + "S-2": { + "note": "what are the little dots there that arent pieces?", + "v": "yes", + "seconds": 32, + "theme": "dark", + "zoom": 1 + } + } +} \ No newline at end of file diff --git a/scripts/close-out.sh b/scripts/close-out.sh index 19db94e3..e6961728 100755 --- a/scripts/close-out.sh +++ b/scripts/close-out.sh @@ -15,6 +15,8 @@ # Every check is scoped to the branch you name. A workspace-wide version of the # docs checks was tried and rejected: it produced 78 warnings on its first run, # including a live-but-never-pushed branch and a file path that looked like one. +# +# Contract section: feature-flow design §4 set -uo pipefail BRANCH="${1:-}" @@ -28,6 +30,8 @@ case "$REPO" in *) REPO_DIR="$WORKSPACE/$REPO" ;; esac [[ -e "$REPO_DIR/.git" ]] || { echo "close-out: no git repo at $REPO_DIR"; exit 0; } +# Where contracts are looked for. Overridable so the test can point it at a temp folder. +DOCS_DIR="${CLOSE_OUT_DOCS:-$WORKSPACE/docs}" pass() { printf ' \033[32mOK\033[0m %s\n' "$1"; } fail() { printf ' \033[31mTODO\033[0m %s\n' "$1"; FAILED=$((FAILED+1)); } @@ -112,6 +116,42 @@ else fi fi +echo +echo "Contract" +# The contract is the definition of done for a feature (docs/active/specs/2026-09-01-feature-flow-design.md). +# It names its branch, so this is the ONLY lookup — no "branch" field, no contract, and the +# note below says so rather than guessing which deck folder this work came from. +# Fix: a fixed-string match on `"branch": "$BRANCH"` missed a contract written without the +# space after the colon (`"branch":"$BRANCH"`, still valid JSON) — a contract could sit right +# there and this would still say "no contract names this branch". Match the colon loosely +# instead. $BRANCH can contain "/" and "." — a "." in the regex also matches a literal "." +# so that alone is harmless, but escape every regex metacharacter anyway so the intent reads +# honestly as "escaped for regex use", not "happens to work". +BRANCH_RE=$(printf '%s' "$BRANCH" | sed 's/[.[\*^$]/\\&/g') +CONTRACTS=$(rg -l --glob '*.contract.json' -e "\"branch\"[[:space:]]*:[[:space:]]*\"$BRANCH_RE\"" "$DOCS_DIR" 2>/dev/null || true) +if [[ -z "$CONTRACTS" ]]; then + note "no contract names this branch — the feature flow was not used, or the contract has no \"branch\"" +else + while IFS= read -r c; do + REL="${c#"$WORKSPACE"/}" + # contract-check owns every fact (does it hold, was it signed, was acceptance submitted): + # exit 1 + problems on stderr when it does not hold; otherwise `ok:` / `todo:` lines + # that are relayed here verbatim, so this script never reads an answers file itself. + if OUT=$(python3 "$WORKSPACE/scripts/ui-review/review-cards.py" contract-check "$c" 2>&1); then + while IFS= read -r line; do + case "$line" in + ok:\ *) pass "${line#ok: } — $REL" ;; + todo:\ *) fail "${line#todo: }" ;; + *) note "$line" ;; + esac + done <<<"$OUT" + else + fail "contract does not hold — $REL:" + echo "$OUT" | sed 's/^/ /' + fi + done <<<"$CONTRACTS" +fi + echo echo "Docs" diff --git a/scripts/ui-review/README.md b/scripts/ui-review/README.md index e4636134..fb9b2904 100644 --- a/scripts/ui-review/README.md +++ b/scripts/ui-review/README.md @@ -59,7 +59,7 @@ the reason. **A review must quote `coverage.md` and call unverified surfaces "un | `contrast-report.mjs` | aggregates the painted-pixel probe (fg vs *actual* bg) — catches hardcoded colours and translucent surfaces the token audit can't. Over-reports on glass themes; read it, don't paste it. | | `coverage.mjs` | covered / partial / MISSED per surface × theme, with reasons. | | `make-gallery.py` | the HTML gallery. | -| `review-cards.py` + `deck/` + `crops.json` | **the review surface** (v2, 2026-08-27). `build ` cuts 1:1 crops from the run dirs, resolves every highlight box — from the rig's `measure` of a named element, or from the pixel difference between Before and After (the spec never carries coordinates; an optional `labels` map renames the run captions (`{"before": "Round 1", "after": "Round 2"}`)) — and writes the page; it refuses (no page) on a missing picture, an unresolved box, or a broken writing rule. A one-run deck (`runs: {"today": …}`) is a **brief** — its buttons read *build it / leave it* instead of *keep it / revert it*. A **choice step** (`variants: [{id, label, crop, summary, measured?, risk?, highlight?}, …]` instead of `crop/changed/notice`) puts several pictures of ONE question on one page with a pick-one answer (`P-19 pick B`; "None of these" reports `none`) — Destin's rule (2026-08-27): variants of the same thing are one question, never a yes/no each. Its pictures come from the deck's last run. A step may carry its own `themes` list when its picture exists in one theme only (a real-app capture — the terminal, a live session); that step shows just those pills and the deck does not demand the other themes for it (`phase-d-brief.json`, P-20). `serve ` builds, serves on 127.0.0.1 (the root redirects to the deck; folders never list; the exact URL is printed as `[deck] http://…` and kept in `.serve.json`), opens the browser, saves `.answers.json` on every click and **exits when Destin submits** — run it in the background and its exit is the notification, with the feedback summary on stdout; `wait ` blocks on the answers file alone for a session that no longer holds that process. Spec template: `docs/active/design/2026-08-25-ui-audit/phase-c-review-v2.json`. | +| `review-cards.py` + `deck/` + `crops.json` | **the review surface** (v2, 2026-08-27). `build ` cuts 1:1 crops from the run dirs, resolves every highlight box — from the rig's `measure` of a named element, or from the pixel difference between Before and After (the spec never carries coordinates; an optional `labels` map renames the run captions (`{"before": "Round 1", "after": "Round 2"}`)) — and writes the page; it refuses (no page) on a missing picture, an unresolved box, or a broken writing rule. A one-run deck (`runs: {"today": …}`) is a **brief** — its buttons read *build it / leave it* instead of *keep it / revert it*. A **choice step** (`variants: [{id, label, crop, summary, measured?, risk?, highlight?}, …]` instead of `crop/changed/notice`) puts several pictures of ONE question on one page with a pick-one answer (`P-19 pick B`; "None of these" reports `none`) — Destin's rule (2026-08-27): variants of the same thing are one question, never a yes/no each. Its pictures come from the deck's last run. A step may carry its own `themes` list when its picture exists in one theme only (a real-app capture — the terminal, a live session); that step shows just those pills and the deck does not demand the other themes for it (`phase-d-brief.json`, P-20). `serve ` builds, serves on 127.0.0.1 (the root redirects to the deck; folders never list; the exact URL is printed as `[deck] http://…` and kept in `.serve.json`), opens the browser, saves `.answers.json` on every click and **exits when Destin submits** — run it in the background and its exit is the notification, with the feedback summary on stdout; `wait ` blocks on the answers file alone for a session that no longer holds that process. Spec template: `docs/active/design/2026-08-25-ui-audit/phase-c-review-v2.json`. The feature flow (`docs/active/specs/2026-09-01-feature-flow-design.md`) adds two step shapes and two commands on top of this same deck: `"words": true` steps (no picture, `deck/*.py` `test_words.py`) for the pre-build questions round, and a one-step `rows` contract step, gated by `review-cards.py contract-check` (source provenance, guard existence, signoff) and `review-cards.py acceptance` (every mechanical/deck row graded) — see `.claude/rules/feature-flow.md`. | | `review-page.py` | the earlier prose-first review page (Phase A/B pages). Rejected as a review surface on 2026-08-26 — do not use for new phases. | ## Writing a shot @@ -255,20 +255,21 @@ They are `unittest` and `node --test`, not pytest, and they live outside a packa start directory has to be the top level too. `-t .` fails with *"Start directory is not importable"*, which is why nothing ran them for months: -The three binary-free suites, which is what CI runs: +The five binary-free suites, which is what CI runs: ```bash -cd scripts/ui-review/tests && python3 -m unittest test_spec test_tokens test_live +cd scripts/ui-review/tests && python3 -m unittest test_spec test_tokens test_live test_words test_contract ``` -Everything (98 tests, ~12s) — needs `magick`, `ffmpeg` and Chrome, all present on this machine: +Everything (132 tests, ~20s) — needs `magick`, `ffmpeg` and Chrome, all present on this machine: ```bash python3 -m unittest discover -s scripts/ui-review/tests -t scripts/ui-review/tests -p 'test_*.py' node --test scripts/ui-review/tests/deck-render.test.mjs bash scripts/ui-review/tests/probe-ports.test.sh && bash scripts/ui-review/tests/cdp-ports.test.sh +bash scripts/ui-review/tests/close-out-contract.test.sh ``` Both blocks are marked ``, so `scripts/check-doc-commands.mjs` actually runs @@ -278,7 +279,7 @@ months. | Suite | Needs | |---|---| -| `test_spec`, `test_tokens`, `test_live` | nothing — **these three run in `workspace-ci.yml`** | +| `test_spec`, `test_tokens`, `test_live`, `test_words`, `test_contract` | nothing — **these five run in `workspace-ci.yml`** | | `probe-ports.test.sh`, `cdp-ports.test.sh` | `python3` and `ss` (they hold real ports) | | `test_boxes`, `test_build`, `test_crops`, `test_cli`, `test_serve` | `magick` (they cut real crops) | | `deck-render.test.mjs`, `coverage.test.mjs`, `shot-measure.test.mjs` | Chrome; the clip fixture also needs `ffmpeg` | diff --git a/scripts/ui-review/contract-agent.md b/scripts/ui-review/contract-agent.md new file mode 100644 index 00000000..a3204e63 --- /dev/null +++ b/scripts/ui-review/contract-agent.md @@ -0,0 +1,50 @@ +# Contract agent + +You write the contract for a feature: the rows that define "done", built ONLY from what Destin +answered on the decks. You are a fresh agent on purpose — the session that drew the designs +grades its own work generously; you do not. + +## Inputs (you get nothing else) +- Every deck spec for the feature, in order: `.questions.json`, then each review round. +- Their answers files (`*.answers.json`; if a stamped `*.answers..json` exists and the + plain file is unsubmitted, the stamped one is the real answer set). +- `scripts/ui-review/templates/contract.json` — the shape to fill. + +Do NOT read the design spec, the implementation plan, chat transcripts or the code. If the +answers do not support a row, the row does not exist; write what was missed into a +`## Not covered` list at the end of your reply so the next round can ask. + +## How an answer becomes a row +- `yes` with no note, or a note tagged **just noting** → one row. Statement = the step's + headline rewritten as what the user experiences (present tense, no code words — the deck's + banned list applies). `source` = `#`; `note` = the note text verbatim. +- A note with NO tag (answers files older than the tags, 2026-09-01) counts as **just noting**. +- `pick X` → a row stating the picked option's label as a fact ("The invite lives in the + friends list"). Other options are not rows. +- `other` → a row from the note ONLY if it states a requirement; a wish or a question is + `## Not covered`. +- A note tagged **fix now** → NOT a row (it was the next round's work; the next round's + answer is the source). Tagged **fix later** → not a row; list it under `## Roadmap` in your + reply with the source, for the session to file. +- `no` / `skip` → no row. A skipped step is unanswered, never "fine". + +## `checkedBy` +- `mechanical` only when you can name a test or guard path (workspace-relative) that checks + the statement and EXISTS — on disk, or committed on the feature branch you were told + (`contract-check` looks in both places). Do not invent one; if none exists, the row is + `human` and you say so in `## Not covered` ("R4 needs a test"). +- `deck` when the approved step's picture IS the check (re-shot from the built branch). +- `live-app` when only the real running app can show it (sync, other users, terminals). +- `human` otherwise. + +## Rules +- One sentence per statement, in the user's words. ≤ 25 words. +- `threshold` is pass/fail unless a number was approved on the deck (a `measured` field). +- Set `branch` to the feature branch you were told; `sources` maps every deck key you cite to + its spec path relative to the contract file. +- Finish with: `python3 scripts/ui-review/review-cards.py contract-check ` and paste its + output. A contract that does not hold (exit 1) is not delivered; the `todo: not signed` + line is expected — signing is Destin's, after you. +- Write it to `docs/active/design/-/.contract.json` — `close-out.sh` + finds contracts only by that suffix, under `docs/`; anywhere else the gate reports "no + contract names this branch". diff --git a/scripts/ui-review/deck/build.py b/scripts/ui-review/deck/build.py index fb61ac33..a73b152b 100644 --- a/scripts/ui-review/deck/build.py +++ b/scripts/ui-review/deck/build.py @@ -8,7 +8,7 @@ from .crops import image_name from .live import has_live, is_live, live_base, live_offset, pane_url, pane_width -from .spec import SpecError, all_themes, is_choice, is_decide, run_names, step_themes, validate, workspace_root, is_clip, clip_files +from .spec import SpecError, all_themes, is_choice, is_contract, is_decide, is_words, run_names, step_themes, validate, workspace_root, is_clip, clip_files HERE = os.path.dirname(os.path.abspath(__file__)) NICE = {'midnight': 'Midnight', 'dark': 'Dark', 'light': 'Light', 'creme': 'Crème', 'halftone-dimension': 'Halftone', 'meadow-mist': 'Meadow'} @@ -68,19 +68,49 @@ def _choice_step(spec, st, boxes, run): } +def _option(o): + return {'id': o['id'], 'label': o['label'], 'summary': o['summary'], + 'measured': o.get('measured', ''), 'cost': o.get('cost', '')} + + +# The rows keys, verbatim into deck data — page.js draws them as a table (feature-flow design §3). +ROW_KEYS = ('id', 'statement', 'checkedBy', 'guard', 'threshold', 'source', 'note', 'verdict', 'evidence') + + def _decide_step(spec, st, boxes, runs): """One picture (the last run — how it is today) and the written options beside it.""" return { 'id': st['id'], 'kind': 'decide', 'surface': st['surface'], 'path': st['path'], 'headline': st['headline'], 'notice': st.get('notice', ''), 'risk': st.get('risk', ''), - 'options': [{'id': o['id'], 'label': o['label'], 'summary': o['summary'], - 'measured': o.get('measured', ''), 'cost': o.get('cost', '')} for o in st['options']], + 'options': [_option(o) for o in st['options']], 'images': {t: {r: f'{spec["images"]}/{image_name(st["crop"], t, r)}' for r in runs} for t in step_themes(spec, st)}, 'boxes': boxes.get(st['id'], {}), **({'themes': list(st['themes'])} if st.get('themes') else {}), } +def _words_step(spec, st): + """No picture: the cards take the whole row (page.js lays a `words` step out without a + stage). With `rows` it is a contract (a table, signed off yes/no); with `options` it + answers like a decide; without either, like an approve — and `yes`/`no` relabel the + buttons — "Holds / Fails" on an acceptance row, not "Yes, build it".""" + d = {'id': st['id'], 'words': True, 'surface': st['surface'], 'path': st['path'], 'headline': st['headline'], + 'changed': st.get('changed', ''), 'measured': st.get('measured', ''), + 'notice': st.get('notice', ''), 'risk': st.get('risk', ''), + 'yes': st.get('yes', ''), 'no': st.get('no', ''), + **({'themes': list(st['themes'])} if st.get('themes') else {})} + if is_contract(st): + # The rows, verbatim, and the two buttons a sign-off needs; page.js draws `rows` as a table. + d['kind'] = 'contract' + d['rows'] = [{k: r.get(k, '') for k in ROW_KEYS} for r in st['rows']] + d['yes'] = st.get('yes') or 'Yes, that is done' + d['no'] = st.get('no') or 'No, something is missing' + elif st.get('options'): + d['kind'] = 'decide' + d['options'] = [_option(o) for o in st['options']] + return d + + def _clip_step(spec, st, runs): """Before | After (or Today) as recordings. No boxes: motion is the highlight.""" vids, posters = clip_files(spec, st) @@ -125,6 +155,7 @@ def _live_step(spec, st): def deck_data(spec, boxes): runs = run_names(spec) steps = [_live_step(spec, st) if is_live(st) + else _words_step(spec, st) if is_words(st) else _choice_step(spec, st, boxes, runs[-1]) if is_choice(st) else _decide_step(spec, st, boxes, runs) if is_decide(st) else _clip_step(spec, st, runs) if is_clip(st) else { @@ -156,6 +187,8 @@ def build_page(spec, boxes): for st in spec['steps']: if is_live(st): continue # nothing on disk to check — the pane is a running app; page.js probes the server + if is_words(st): + continue # nothing on disk to check — a question, a statement or a contract has no picture if is_clip(st): vids, _ = clip_files(spec, st) for r in runs: diff --git a/scripts/ui-review/deck/contract.py b/scripts/ui-review/deck/contract.py new file mode 100644 index 00000000..f55034dc --- /dev/null +++ b/scripts/ui-review/deck/contract.py @@ -0,0 +1,181 @@ +"""The gate's three facts, and the acceptance deck built from the contract. + +contract-check reads what the design calls the gate (feature-flow design §4): (1) the contract +holds — every row's `source` names a step that exists in a deck the spec's `sources` map points +at, that deck's answers were SUBMITTED, that step was answered (not skipped), and every +`mechanical` guard exists on disk or on the contract's branch; (2) the contract was SIGNED — +its own answers file is submitted and the contract step answered yes; (3) the acceptance deck +was submitted. Only (1) is an exit code: the contract agent runs this before Destin has seen +the deck, so (2) and (3) are reported as `ok:` / `todo:` lines that close-out.sh relays. + +acceptance merges the grader's verdicts into the contract: step 1 is the table with a verdict +beside every graded row, then one words step per human / live-app row for Destin to tick.""" +import glob +import json +import os +import subprocess + +from .spec import is_contract, workspace_root + + +class AcceptanceError(Exception): + pass + + +def contract_steps(spec): + return [st for st in spec['steps'] if is_contract(st)] + + +def _when(stamp): + return (stamp or '')[:16].replace('T', ' ') + + +def guard_exists(root, branch, guard): + """A `mechanical` row's guard, as a workspace-relative path. True if it is on disk under + `root`, or committed on `branch` (or `origin/`) in the repo the path's first + segment names — `youcoded/desktop/tests/x.test.ts` is looked up in the `youcoded` repo as + `desktop/tests/x.test.ts`; `scripts/x.py` in the workspace repo itself. + WHY the branch: from a worktree, workspace_root() is the MAIN checkout, where a test the + feature branch adds does not exist until merge — and those are most of the guards a + contract names. An uncommitted file is found nowhere, on purpose.""" + if not guard: + return False + if os.path.exists(os.path.join(root, guard)): + return True + if not branch: + return False + first, _, rest = guard.partition('/') + repo, rel = (os.path.join(root, first), rest) if rest and os.path.exists(os.path.join(root, first, '.git')) else (root, guard) + for ref in (branch, f'origin/{branch}'): + try: + r = subprocess.run(['git', '-C', repo, 'cat-file', '-e', f'{ref}:{rel}'], capture_output=True) + except OSError: + # Fix: no git binary on PATH must read as "guard not found", never a traceback + # in close-out — the disk check above already ran, so this is the only fallback left. + return False + if r.returncode == 0: + return True + return False + + +def answers_for(spec_path): + """(raw spec, newest SUBMITTED answers, why) for a source deck. Returns (None, None, why) + when the spec cannot be read; (spec, None, why) when nothing submitted exists. + WHY the glob: serve.rotate_submitted moves a submitted file to .answers..json + before a re-serve, so the plain file may be the EMPTY new one while the decisions sit in + the stamped one. The newest submitted file wins, whichever name it carries.""" + try: + with open(spec_path) as f: + raw = json.load(f) + except (OSError, ValueError) as e: + return None, None, f'cannot read {os.path.basename(spec_path)}: {e}' + base, stem = os.path.dirname(spec_path), os.path.splitext(os.path.basename(spec_path))[0] + candidates = [os.path.join(base, stem + '.answers.json')] + sorted(glob.glob(os.path.join(base, stem + '.answers.*.json')), reverse=True) + seen_any = False + for c in candidates: + try: + with open(c) as f: + a = json.load(f) + except (OSError, ValueError): + continue + seen_any = True + if a.get('submitted'): + return raw, a, '' + rel = os.path.basename(spec_path) + return raw, None, (f'{rel} answers were never submitted' if seen_any else f'{rel} has no answers file') + + +def check_contract(spec): + """One problem per line; empty means the contract holds together.""" + problems, cache = [], {} + sources = spec.get('sources') or {} + root = workspace_root() + for st in contract_steps(spec): + for r in st['rows']: + tag = f'{st["id"]}/{r["id"]}' + key, _, sid = (r.get('source') or '').partition('#') + rel = sources.get(key) + if not rel: + problems.append(f'{tag}: source deck "{key}" is not in the spec\'s "sources"') + continue + if key not in cache: + cache[key] = answers_for(os.path.join(spec['_base'], rel)) + raw, ans, why = cache[key] + if raw is None: + problems.append(f'{tag}: {why}') + continue + if raw.get('key') != key: + problems.append(f'{tag}: {rel} is deck "{raw.get("key")}", not "{key}"') + if sid not in {s.get('id') for s in raw.get('steps', [])}: + problems.append(f'{tag}: no step "{sid}" in {rel}') + if ans is None: + problems.append(f'{tag}: {why}') + continue + a = (ans.get('answers') or {}).get(sid) or {} + if not a.get('v') or a['v'] == 'skip': + problems.append(f'{tag}: step {sid} of {key} was not answered') + if r.get('checkedBy') == 'mechanical' and not guard_exists(root, spec.get('branch'), r.get('guard', '')): + problems.append(f'{tag}: guard {r.get("guard")} is neither on disk under {root} nor committed on branch "{spec.get("branch") or "(no branch in the spec)"}"') + return problems + + +def signoff(spec): + """Fact (2): the contract's OWN answers — submitted, and the contract step answered yes. + Returns (ok, one line for close-out).""" + steps = contract_steps(spec) + sid = steps[0]['id'] if steps else None + _, ans, why = answers_for(os.path.join(spec['_base'], spec['_stem'] + '.json')) + if ans is None: + return False, f'not signed — {why}; serve {spec["_stem"]}.json and answer it' + a = (ans.get('answers') or {}).get(sid) or {} + if a.get('v') == 'yes': + return True, f'signed {_when(ans.get("submitted"))} — {sid} yes' + (f' — "{a["note"].strip()}"' if (a.get('note') or '').strip() else '') + if a.get('v') in ('no', 'other'): + return False, f'answered "{a["v"]}" {_when(ans.get("submitted"))} — the contract is not agreed' + (f': "{a["note"].strip()}"' if (a.get('note') or '').strip() else '') + return False, f'not signed — submitted {_when(ans.get("submitted"))} but step {sid} was skipped' + + +def acceptance_status(spec): + """Fact (3): `.acceptance.json` exists and its newest answers file is submitted.""" + acc = os.path.join(spec['_base'], spec['_stem'] + '.acceptance.json') + if not os.path.exists(acc): + return False, f'acceptance deck not built — write {spec["_stem"]}.verdicts.json, then review-cards.py acceptance {spec["_stem"]}.json' + raw, ans, why = answers_for(acc) + if ans is None: + # Fix: a corrupt/unreadable acceptance deck (raw is None) is a different problem than + # "built but nobody submitted it" — name the real cause instead of assuming the latter. + if raw is None: + return False, f'acceptance deck unreadable — {why}' + return False, f'acceptance deck not submitted — serve {os.path.basename(acc)}' + return True, f'acceptance deck submitted {_when(ans.get("submitted"))}' + + +GRADED = ('mechanical', 'deck') + + +def acceptance_spec(spec, verdicts): + """The acceptance deck as a spec dict. `verdicts` is {row id: {verdict, evidence}} from + .verdicts.json. Refuses when a graded row has none: an ungraded row is not a pass.""" + steps = contract_steps(spec) + if len(steps) != 1: + raise AcceptanceError(f'expected exactly one contract step, found {len(steps)}') + st = steps[0] + missing = [f'{r["id"]} ({r["checkedBy"]})' for r in st['rows'] if r.get('checkedBy') in GRADED and not (verdicts.get(r['id']) or {}).get('verdict')] + if missing: + raise AcceptanceError('no verdict for graded rows: ' + ', '.join(m + ' has no verdict' for m in missing)) + rows = [] + for r in st['rows']: + v = verdicts.get(r['id']) or {} + rows.append({**r, **({'verdict': v['verdict'], 'evidence': v.get('evidence', '')} if v.get('verdict') else {})}) + table = {**st, 'id': st['id'], 'rows': rows, 'headline': 'The contract, graded — accept these verdicts?', + 'yes': 'Yes, accept', 'no': 'No, something is wrong'} + human = [{'id': r['id'], 'words': True, 'surface': st['surface'], 'path': 'Acceptance', + 'headline': r['statement'], + 'changed': 'Checked by you.' + (f' Your note at review: “{r["note"]}”' if r.get('note') else ''), + 'notice': r.get('threshold') or 'pass / fail', + 'yes': 'Holds', 'no': 'Fails'} + for r in st['rows'] if r.get('checkedBy') in ('human', 'live-app')] + return {'title': spec['title'] + ' — acceptance', 'key': spec['key'] + '-acceptance', + 'out': spec['_stem'] + '.acceptance.html', 'themes': list(spec['themes']), + 'branch': spec.get('branch', ''), 'sources': dict(spec.get('sources') or {}), + 'steps': [table] + human} diff --git a/scripts/ui-review/deck/crops.py b/scripts/ui-review/deck/crops.py index 799b06ce..171de2eb 100644 --- a/scripts/ui-review/deck/crops.py +++ b/scripts/ui-review/deck/crops.py @@ -7,8 +7,8 @@ import subprocess from .boxes import diff_bbox, image_size, px_to_pct, rect_to_pct -from .live import all_live, is_live -from .spec import AUTO_WARN_FRACTION, is_choice, run_names, step_themes, is_clip +from .live import is_live +from .spec import AUTO_WARN_FRACTION, is_choice, is_words, no_pictures, run_names, step_themes, is_clip def image_name(crop, theme, run): @@ -36,9 +36,9 @@ def newest_manifest_entry(run_dir, plan, shot, theme): def crop_images(spec, log=print): - # A deck whose every step is LIVE has no stills and names no `images` folder — the very - # next line would KeyError on it before the loop below ever gets a chance to skip anything. - if all_live(spec): + # A deck whose every step is LIVE or WORDS-ONLY has no stills and names no `images` folder + # — the very next line would KeyError on it before the loop below ever gets a chance to skip anything. + if no_pictures(spec): return {'boxes': {}, 'missing': [], 'warnings': [], 'count': 0} out_dir = os.path.join(spec['_base'], spec['images']) os.makedirs(out_dir, exist_ok=True) @@ -51,6 +51,8 @@ def crop_images(spec, log=print): # (Same reason validate() dispatches live first.) if is_live(st): continue # a running app, not a still — nothing to cut, and no `crop` to look up + if is_words(st): + continue # words only (a question, a statement, a contract) — nothing to cut, no `crop` to look up if is_choice(st): _crop_choice(spec, st, runs[-1], out_dir, boxes, missing, cut) continue diff --git a/scripts/ui-review/deck/live.py b/scripts/ui-review/deck/live.py index 1481029f..9c992222 100644 --- a/scripts/ui-review/deck/live.py +++ b/scripts/ui-review/deck/live.py @@ -36,7 +36,9 @@ def has_live(spec): def all_live(spec): """A deck with no pictures at all: it names no `images` folder and no `runs`, so every code path that reaches for either has to bail out before it does (spec.py, crops.py, - build.py, review-cards.py).""" + build.py, review-cards.py). + Superseded by `spec.no_pictures` for every production path; kept because `test_live.py` + pins it.""" return bool(spec['steps']) and all(is_live(st) for st in spec['steps']) diff --git a/scripts/ui-review/deck/page.css b/scripts/ui-review/deck/page.css index 20195ed3..966ac766 100644 --- a/scripts/ui-review/deck/page.css +++ b/scripts/ui-review/deck/page.css @@ -70,6 +70,13 @@ body.thumbs-inline .thumbs{position:static;flex-direction:row;justify-content:fl .content.row-below{grid-template-columns:1fr;grid-template-rows:1fr auto;grid-template-areas:"stage" "decide"} .content.col-right{grid-template-columns:1fr minmax(320px,30%);grid-template-rows:1fr;grid-template-areas:"stage decide"} .content.compact{display:flex;flex-direction:column;flex:none} +/* WORDS: no stage at all — the question and its option cards take the row (feature-flow §5) */ +.content.words{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"decide"} .content.words .stage{display:none} +.words .cards{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))} .words .card.option{min-height:96px} +/* CONTRACT: the rows as a table; a graded row is tinted by its verdict */ +.card.contract{overflow:auto} .card.contract table{border-collapse:collapse;width:100%;font-size:12px} .card.contract th{text-align:left;font-weight:600;color:var(--fg-dim);padding:6px 8px;border-bottom:1px solid var(--edge)} .card.contract td{padding:6px 8px;border-bottom:1px solid var(--edge);vertical-align:top} +.card.contract .src{margin:2px 0 0;font-size:11px;color:var(--fg-muted)} .card.contract tr.pass td:last-child{color:var(--yes)} .card.contract tr.fail td:last-child{color:var(--no)} +.words .cards:has(.contract){grid-template-columns:1fr} .compact .stage{flex:none;overflow:visible} .compact .stage .inner{flex-direction:column;align-items:center} .compact .info{overflow:visible} /* COMPACT is the exception, on purpose. `.controls` is sticky to the bottom there so the answer buttons stay reachable in a narrow window, and sticky needs room to move inside its @@ -163,6 +170,9 @@ figcaption .key{color:var(--mark);font-weight:700} .ans .key{flex:none} .ans .dot{width:clamp(8px,0.7vw,11px);height:clamp(8px,0.7vw,11px)} #save{height:clamp(34px,4.4vh,52px);font-size:clamp(12px,0.95vw,15px);padding:0 clamp(14px,1.4vw,26px)} .note{flex:3 1 220px;font:inherit;font-size:clamp(12px,0.85vw,14px);height:clamp(34px,4.4vh,52px);padding:0 12px;border:1px solid var(--edge);border-radius:var(--radius-md);background:var(--well);color:var(--fg)} .note::placeholder{color:var(--fg-muted)} +/* Note tags: what the note IS. Shown only once a note has text; "Just noting" is preselected so nothing is inferred. */ +.tags{display:inline-flex;gap:6px} .tag{font:inherit;font-size:11px;padding:4px 9px;border:1px solid var(--edge);border-radius:999px;background:var(--well);color:var(--fg-dim);cursor:pointer} .tag.on{border-color:var(--mark);color:var(--fg);box-shadow:inset 0 0 0 1px var(--mark)} +.compact .controls .tags{grid-column:1/4} .veil{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:30} .veil.on{display:flex} .dlg{width:min(520px,92vw);background:var(--panel);border:1px solid var(--edge);border-radius:var(--radius-lg);padding:20px;box-shadow:0 20px 60px rgba(0,0,0,.5)} .dlg h2{margin:0 0 10px;font-size:16px;font-weight:500} .dlg p{margin:0 0 10px;color:var(--fg-2);font-size:13px;line-height:1.5} .dlg .warn{display:flex;gap:8px;align-items:flex-start;background:var(--inset);border:1px solid color-mix(in srgb, var(--mark) 45%, var(--edge));border-radius:var(--radius-md);padding:10px 12px;color:var(--fg);margin:12px 0} diff --git a/scripts/ui-review/deck/page.html.tmpl b/scripts/ui-review/deck/page.html.tmpl index a610e4fb..dee75ad0 100644 --- a/scripts/ui-review/deck/page.html.tmpl +++ b/scripts/ui-review/deck/page.html.tmpl @@ -17,6 +17,8 @@
+ +
diff --git a/scripts/ui-review/deck/page.js b/scripts/ui-review/deck/page.js index 62a7fe3d..f48f5dbd 100644 --- a/scripts/ui-review/deck/page.js +++ b/scripts/ui-review/deck/page.js @@ -10,6 +10,9 @@ change: '', eye: '', warn: '' }; + // Mirrors serve.py's NOTE_KIND: an unknown/absent note_kind (an old answers file predating + // tags) prints nothing in the summary rather than "[undefined]". + const NOTE_KIND = { now: 'fix now', later: 'fix later', noting: 'just noting' }; const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); if (window.top !== window) document.body.classList.add('embedded'); // What the stage shows for a step: one frame per run (before/after, or today), or — for a CHOICE @@ -102,6 +105,9 @@ } // A one-run deck is a BRIEF (nothing built yet): "keep / revert" would ask about work that does not exist. const YES = runs.length === 1 ? 'Yes, build it' : 'Yes, keep it', NO = runs.length === 1 ? 'No, leave it' : 'No, revert it'; + // A words step may relabel the buttons ("Holds / Fails" on an acceptance row): the deck's + // build/keep wording is about a picture, and a statement has none. + const yesLabel = st => st.yes || YES, noLabel = st => st.no || NO; // The things a step offers to pick between, or null if it is a yes/no. A LIVE step's // question shape rides in `shape`, because `kind` already says where its picture comes // from — so a live pick-one answers exactly like a picture pick-one. @@ -121,7 +127,7 @@ $('#answers').innerHTML = picks || st.kind === 'decide' ? (picks ? `` : '') + `` - : ``; + : ``; if (state.submitted) $$('.ans').forEach(e => e.disabled = true); // the buttons are rebuilt per step; a submitted deck stays read-only } function answer(v, pick) { @@ -153,14 +159,15 @@ lastStep = st; document.documentElement.dataset.theme = theme; // before the pictures load, so a theme switch never flashes the old colours $('#wtitle').textContent = st.surface; $('#wsub').textContent = st.path; - curFrames = frames(st); + // A words step has no frames: the stage is hidden by layout() and the cards fill the row. + curFrames = st.words ? [] : frames(st); inner.innerHTML = curFrames.map(f => `
${f.caption}
${media(st, f)}
`).join(''); $$('#inner .frame .box').forEach(box => { const b = ((st.boxes || {})[theme] || {})[box.closest('.frame').dataset.run]; if (b) box.style.cssText = `left:${b[0]}%;top:${b[1]}%;width:${b[2]}%;height:${b[3]}%`; else box.style.display = 'none'; }); $('#replay').hidden = st.kind !== 'clip'; // Zoom is off on a live step: it scales a still image, and a pane at anything but real // size stops showing the thing it is there to show. (The magnifier needs no switch — its // handler looks for `#inner img`, finds none, and hides itself.) - $('#zoom').hidden = st.kind === 'live'; + $('#zoom').hidden = st.kind === 'live' || !!st.words; $('#livehint').hidden = st.kind !== 'live'; if (st.kind === 'live') { // Once a click lands inside a pane, focus is in ITS document and this page stops seeing @@ -177,7 +184,15 @@ $$('#inner .frame.pickable').forEach(f => f.onclick = () => answer('pick', f.dataset.run)); $('#headline').textContent = st.headline; const optionCard = (o, cls) => `
${esc(o.id)}

${esc(o.label)}

${esc(o.summary)}

${o.measured ? `

Measured: ${esc(o.measured)}

` : ''}${o.cost ? `

${esc(o.cost)}

` : ''}${o.risk ? `

${esc(o.risk)}

` : ''}
`; - $('#cards').innerHTML = pickList(st) + // CONTRACT: the rows as one table, not a card per row — grading (a `verdict` on any row) + // adds a Verdict column instead of always reserving one nobody has filled in yet. + const graded = st.kind === 'contract' && st.rows.some(r => r.verdict); + const rowsTable = () => `
${graded ? '' : ''}${st.rows.map(r => `${graded ? `` : ''}`).join('')}
#StatementChecked byThresholdFromVerdict
${esc(r.id)}${esc(r.statement)}${r.note ? `

“${esc(r.note)}”

` : ''}
${esc(r.checkedBy)}${r.guard ? `

${esc(r.guard)}

` : ''}
${esc(r.threshold || 'pass / fail')}${esc(r.source)}${esc(r.verdict || '—')}${r.evidence ? `

${esc(r.evidence)}

` : ''}
`; + $('#cards').innerHTML = st.kind === 'contract' + ? rowsTable() + + (st.notice ? `

${ICON.eye}You'll notice

${esc(st.notice)}

` : '') + + (st.risk ? `

${ICON.warn}Risk

${esc(st.risk)}

` : '') + : pickList(st) ? pickList(st).map(v => optionCard(v, '')).join('') + (st.notice ? `

${ICON.eye}You'll notice

${esc(st.notice)}

` : '') + (st.risk ? `

${ICON.warn}Risk

${esc(st.risk)}

` : '') @@ -194,9 +209,10 @@ // live step, where the pane deliberately isn't a pick target and the card is the big one. $$('.card.variant').forEach(c => c.onclick = () => answer('pick', c.dataset.pick)); renderAnswers(st); - const last = curFrames[curFrames.length - 1].key; + const last = curFrames.length ? curFrames[curFrames.length - 1].key : null; // A clip is recorded in one theme; there are no per-theme variants to switch between. - $('#thumbs').innerHTML = st.kind === 'clip' ? '' + // A words step has no picture of any theme — the theme pills would be empty. + $('#thumbs').innerHTML = st.words ? '' : st.kind === 'clip' ? '' // A live step has no thumbnails to show — there is no picture of the other themes, only // the panes themselves, one theme at a time. Same control, rendered as plain labels. : st.kind === 'live' ? themes.map(t => ``).join('') @@ -215,6 +231,13 @@ $$('.card.variant').forEach(c => c.classList.toggle('on', a.v === 'pick' && c.dataset.pick === a.pick)); $$('#inner .frame.pickable').forEach(f => f.classList.toggle('on', a.v === 'pick' && f.dataset.run === a.pick)); const note = $('#note'); note.value = a.note || ''; note.placeholder = a.v === 'other' ? 'Explain what you’d like instead…' : 'Add a note (optional)'; + // The tag row is shown only once a note has text — nothing about an empty note is tagged. + // A visible default, never an inference: an old answer's note (written before tags existed) + // has no note_kind, so it shows none selected — the default is written only when a note + // gains text under the #note input handler below, never painted on here. + const hasNote = !!(a.note && a.note.trim()); + $('#tags').hidden = !hasNote; + $$('#tags .tag').forEach(b => b.classList.toggle('on', hasNote && b.dataset.kind === a.note_kind)); $$('#steps span').forEach((s, i) => { const x = state.answers[DECK.steps[i].id]; s.className = (x && x.v ? x.v : '') + (i === cur ? ' on' : ''); }); const done = Object.values(state.answers).filter(x => x.v && x.v !== 'skip').length; $('#count').textContent = 'step ' + (cur + 1) + ' of ' + N + ' · ' + done + ' answered' + (state.submitted ? ' · submitted, read-only' : ''); // survives every repaint (theme clicks included) @@ -224,6 +247,15 @@ // ── layout: try each arrangement for real, keep the one that shows the pictures largest (spec §3.4) ── const PAD = 28, CAP = 24, GAP = 18; function layout() { + if (DECK.steps[cur].words) { // no picture to size: one column of cards, answer bar under it + $('#content').className = 'content words'; $('#step').classList.remove('compact-step'); + document.body.dataset.layout = 'words'; + // Fix: without this, navigating from a picture step to a words step left the PREVIOUS + // step's scores in the DOM — a stale table the render test (and anyone reading the DOM + // by hand) could mistake for this step's own layout choice. Match the live branch. + document.body.dataset.scores = '{}'; + window.__deckReady = true; return; + } if (DECK.steps[cur].kind === 'live') { layoutLive(); return; } const c = $('#content'), step = $('#step'); const img = $('#inner img, #inner video'); if (!img) return; const natW = img.naturalWidth || img.videoWidth, natH = img.naturalHeight || img.videoHeight; if (!natW) return; @@ -269,7 +301,18 @@ // at speed is one POST, not one per keystroke. let noteTimer = null; $('#answers').addEventListener('click', e => { const b = e.target.closest('.ans'); if (b && !b.disabled) answer(b.dataset.v, b.dataset.pick); }); - $('#note').addEventListener('input', e => { const id = DECK.steps[cur].id; state.answers[id] = { ...(state.answers[id] || {}), note: e.target.value }; clearTimeout(noteTimer); noteTimer = setTimeout(save, 300); }); + $('#note').addEventListener('input', e => { + const id = DECK.steps[cur].id; const a = { ...(state.answers[id] || {}), note: e.target.value }; + // A note that just gained text is "just noting" until he says otherwise — a visible default, + // not an inference: it is on screen, selected, and one click away from the other two. + if (a.note.trim() && !a.note_kind) a.note_kind = 'noting'; + if (!a.note.trim()) delete a.note_kind; + state.answers[id] = a; paintState(); clearTimeout(noteTimer); noteTimer = setTimeout(save, 300); + }); + $('#tags').addEventListener('click', e => { + const b = e.target.closest('.tag'); if (!b || state.submitted) return; + const id = DECK.steps[cur].id; state.answers[id] = { ...(state.answers[id] || {}), note_kind: b.dataset.kind }; paintState(); save(); + }); $('#save').onclick = () => { if (cur === N - 1) openDialog(); else go(cur + 1); }; $('#next').onclick = () => go(cur + 1); $('#prev').onclick = () => go(cur - 1); $$('#steps span').forEach((s, i) => s.onclick = () => go(i)); @@ -277,7 +320,8 @@ // ── submit ── function summary() { const counts = { yes: 0, no: 0, other: 0, skip: 0 }; const lines = []; - for (const st of DECK.steps) { const a = state.answers[st.id] || { v: 'skip' }; const v = a.v || 'skip'; counts[v] = (counts[v] || 0) + 1; const what = v === 'pick' ? 'pick ' + (a.pick || '?') : (v === 'no' && pickList(st) ? 'none' : v); lines.push(st.id + ' ' + what + (a.note && a.note.trim() ? ' — "' + a.note.trim() + '"' : '')); } + // Mirrors serve.py's summary(): the note's tag prints right after its quoted text, same words. + for (const st of DECK.steps) { const a = state.answers[st.id] || { v: 'skip' }; const v = a.v || 'skip'; counts[v] = (counts[v] || 0) + 1; const what = v === 'pick' ? 'pick ' + (a.pick || '?') : (v === 'no' && pickList(st) ? 'none' : v); const tag = NOTE_KIND[a.note_kind]; lines.push(st.id + ' ' + what + (a.note && a.note.trim() ? ' — "' + a.note.trim() + '"' + (tag ? ' [' + tag + ']' : '') : '')); } return DECK.key + ' · ' + (state.submitted ? 'submitted ' + state.submitted.slice(0, 16).replace('T', ' ') : 'not submitted') + ' · ' + counts.yes + ' yes · ' + counts.no + ' no · ' + counts.other + ' other · ' + (counts.pick ? counts.pick + ' picked · ' : '') + counts.skip + ' skipped\n' + lines.join('\n'); } function openDialog() { @@ -313,7 +357,7 @@ $('#veil').classList.remove('on'); lockSubmitted(); }; // A submitted deck is read-only, and says so — silently ignoring clicks read as "I can't click through the pages". - function lockSubmitted() { $('#done').textContent = 'Submitted ✓'; $('#done').disabled = true; $$('.ans,#save,#note').forEach(e => e.disabled = true); paintState(); } + function lockSubmitted() { $('#done').textContent = 'Submitted ✓'; $('#done').disabled = true; $$('.ans,#save,#note,.tag').forEach(e => e.disabled = true); paintState(); } $('#copy').onclick = () => { const t = $('#feedback'); t.select(); (navigator.clipboard ? navigator.clipboard.writeText(t.value) : Promise.reject()).catch(() => document.execCommand('copy')); $('#copy').textContent = 'Copied'; }; // ── loupe, zoom, keys ── diff --git a/scripts/ui-review/deck/serve.py b/scripts/ui-review/deck/serve.py index 500636b8..be9dfc2f 100644 --- a/scripts/ui-review/deck/serve.py +++ b/scripts/ui-review/deck/serve.py @@ -21,6 +21,10 @@ from .live import VITE_BASE_PORT, has_live, live_offset from .spec import SpecError, workspace_root +# The tag says what a note IS — next-round work, a roadmap line, or a remark — so the +# contract agent (Task 6) can route it instead of guessing (feature-flow design §5). +NOTE_KIND = {'now': 'fix now', 'later': 'fix later', 'noting': 'just noting'} + def answers_path(spec): return os.path.join(spec['_base'], spec['_stem'] + '.answers.json') @@ -48,7 +52,10 @@ def summary(spec, state): note = (a.get('note') or '').strip() # A choice step answers with the variant it picked ("P-19 pick B"); "no" there means none of them. what = f'pick {a.get("pick", "?")}' if v == 'pick' else ('none' if v == 'no' and st.get('variants') else v) - lines.append(f'{st["id"]} {what}' + (f' — "{note}"' if note else '')) + # The tag says what the note IS — next-round work, a roadmap line, or a remark — so the + # contract agent routes it instead of guessing (feature-flow design §5). + tag = NOTE_KIND.get(a.get('note_kind'), '') + lines.append(f'{st["id"]} {what}' + (f' — "{note}"' + (f' [{tag}]' if tag else '') if note else '')) when = (state.get('submitted') or '')[:16].replace('T', ' ') head = (f'{spec["key"]} · {"submitted " + when if when else "not submitted"} · ' f'{counts["yes"]} yes · {counts["no"]} no · {counts["other"]} other · ' diff --git a/scripts/ui-review/deck/spec.py b/scripts/ui-review/deck/spec.py index 578fc1d7..dc2db279 100644 --- a/scripts/ui-review/deck/spec.py +++ b/scripts/ui-review/deck/spec.py @@ -8,7 +8,7 @@ import os import re -from .live import PANE_WIDTH, all_live, is_live, pane_width +from .live import PANE_WIDTH, is_live, pane_width HERE = os.path.dirname(os.path.abspath(__file__)) UI_REVIEW = os.path.dirname(HERE) @@ -22,6 +22,11 @@ OPTION_TEXT_FIELDS = ['label', 'summary', 'measured', 'cost'] HEADLINE_MAX = 25 RISK_WARN = 40 +# A contract row's `checkedBy` — who resolves it: a guard script, this deck's own answers, +# a running app probe, or a person. Task 4 reads this to pick its resolver. +CHECKED_BY = ('mechanical', 'deck', 'live-app', 'human') +# # — the answered step a row's verdict comes from. +SOURCE_RE = re.compile(r'^[\w.-]+#[\w.-]+$') # Each live pane boots its own copy of the app; four is the cap (spec: Non-goals). MAX_LIVE_PANES = 4 # Wider than this and the row scrolls sideways, which defeats comparing the panes at all. @@ -56,10 +61,10 @@ def load_spec(path): for k in ('title', 'key', 'out', 'steps'): if k not in spec or spec[k] is None: raise SpecError(f'spec is missing "{k}"') - # `images` and `runs` describe a screenshot sweep. A deck whose every step is LIVE has no - # screenshots to point at, so requiring them would make the author invent a folder and a + # `images` and `runs` describe a screenshot sweep. A deck whose every step is LIVE, WORDS-ONLY or a + # CONTRACT has no screenshots to point at, so requiring them would make the author invent a folder and a # run that nothing ever reads. Everything downstream still wants a run NAME, so default it. - if all_live(spec): + if no_pictures(spec): spec.setdefault('runs', {'today': None}) else: for k in ('images', 'runs'): @@ -118,6 +123,33 @@ def is_clip(step): return bool(step.get('clip')) +def is_contract(step): + """A CONTRACT step is the rows that define done (feature-flow design §3), rendered as a + table and answered yes/no/other as ONE step. It is a WORDS step (is_words is true for it) + that carries `rows`; keyed on the key's PRESENCE so an empty `rows: []` still reaches the + contract validator ("a contract with no rows defines nothing") instead of the picture + one ("missing crop"). Validation and the page come in Task 3 of the plan.""" + return 'rows' in step + + +def is_words(step): + """A WORDS-ONLY step has no picture at all — `"words": true`, an explicit flag rather than + "no crop", so a step that merely forgot its crop is still an error and never renders + silently pictureless. With `options` it is a decide (pick one of the written options, or + Other); with `rows` a contract; otherwise a statement to approve (`changed` + `notice` + are its body). Users: the QUESTIONS deck answered before anything is drawn, the contract, + and the acceptance deck's human rows (feature-flow design §3, §5, §7).""" + return step.get('words') is True or is_contract(step) + + +def no_pictures(spec): + """A deck with no picture steps at all — every step is live or words-only (a contract is + words-only). It names no `images` folder and no `runs`; every code path that reaches for + either bails out first (load_spec, crops.py, build.py, review-cards.py). Widens + live.all_live.""" + return bool(spec['steps']) and all(is_live(st) or is_words(st) for st in spec['steps']) + + def clip_files(spec, step): """{run: relative path to the .webm} for a clip step, and the same for posters (.webp).""" c = step['clip'] @@ -176,6 +208,9 @@ def validate(spec): if is_live(st): _validate_live(spec, st, sid, errors, warnings) continue + if is_words(st): + _validate_words(spec, st, sid, errors, warnings) + continue if is_choice(st): _validate_choice(spec, st, sid, errors, warnings) continue @@ -231,10 +266,24 @@ def _validate_decide(spec, st, sid, errors, warnings): errors.append(f'{sid}: highlight must have selector, text or box') elif 'box' in hl: warnings.append(f'{sid}: hand-placed box — prefer a selector so the rig measures it') + _validate_options(st, sid, errors, warnings, minimum=2) + th = st.get('themes') + if th is not None and (not isinstance(th, list) or not th or not all(isinstance(t, str) for t in th)): + errors.append(f'{sid}: themes must be a non-empty list of theme names') + if word_count(st.get('risk')) > RISK_WARN: + warnings.append(f'{sid}: risk is {word_count(st["risk"])} words — keep it to one sentence') + + +def _validate_options(st, sid, errors, warnings, minimum): + """The written options of a decide step. `minimum` is 2 for a picture decide (one option + plus Other is a yes/no step in disguise) and 1 for a words-only question, where the + recommended answer alone plus Other is exactly the shape Destin asked for (2026-09-01).""" opts = st['options'] - if not isinstance(opts, list) or len(opts) < 2: - errors.append(f'{sid}: a decide step needs at least 2 options') + if not isinstance(opts, list) or len(opts) < minimum: + errors.append(f'{sid}: a decide step needs at least {minimum} option{"s" if minimum > 1 else ""}') return + if len(opts) > 3: + warnings.append(f'{sid}: {len(opts)} options — more than three usually means two questions') seen = set() for i, o in enumerate(opts): oid = o.get('id') or f'option {i + 1}' @@ -251,6 +300,34 @@ def _validate_decide(spec, st, sid, errors, warnings): errors.append(f'{sid}/{oid}: {k} uses banned word "{w}"') if o.get('measured') and not re.search(r'\d', o['measured']): warnings.append(f'{sid}/{oid}: measured has no number in it') + + +def _validate_words(spec, st, sid, errors, warnings): + """No picture, so every picture field is refused rather than required — the same stance + as _validate_live. The question shape is the existing one: `options` → pick one. A + contract (`rows`) is validated by _validate_rows, which this dispatches to.""" + for k in ('surface', 'path', 'headline'): + if not st.get(k): + errors.append(f'{sid}: missing {k}') + for k in ('crop', 'clip', 'highlight', 'variants', 'live'): + if st.get(k): + errors.append(f'{sid}: a words step has no {k} — there is no picture') + _headline_and_words(st, sid, errors) + if is_contract(st): + _validate_rows(spec, st, sid, errors) + elif st.get('options'): + _validate_options(st, sid, errors, warnings, minimum=1) + else: + for k in ('changed', 'notice'): + if not st.get(k): + errors.append(f'{sid}: missing {k} (a words step with no options is a statement to approve; these are its body)') + for k in ('yes', 'no'): + if st.get(k) and word_count(st[k]) > 4: + errors.append(f'{sid}: {k} label is {word_count(st[k])} words — a button, keep it under 5') + # The label is button copy Destin reads — the banned-word rule applies to every + # user-facing field, not just the ones with a word-count cap. + for w in banned_in(st.get(k)): + errors.append(f'{sid}: {k} label uses banned word "{w}"') th = st.get('themes') if th is not None and (not isinstance(th, list) or not th or not all(isinstance(t, str) for t in th)): errors.append(f'{sid}: themes must be a non-empty list of theme names') @@ -258,6 +335,54 @@ def _validate_decide(spec, st, sid, errors, warnings): warnings.append(f'{sid}: risk is {word_count(st["risk"])} words — keep it to one sentence') +def _validate_rows(spec, st, sid, errors): + """The rows of a contract step (feature-flow design §3). Each row is one statement of what + done means, who checks it, and the answered deck step it came from.""" + if st.get('options'): + errors.append(f'{sid}: a contract step has no options — the rows are its body') + rows = st['rows'] + if not isinstance(rows, list): + errors.append(f'{sid}: rows must be a list') + return + sources = spec.get('sources') or {} + seen = set() + for i, r in enumerate(rows): + rid = r.get('id') or f'row {i + 1}' + if not r.get('id'): + errors.append(f'{sid}: {rid} has no id') + elif r['id'] in seen: + errors.append(f'{sid}: duplicate row id "{r["id"]}"') + seen.add(r.get('id')) + if not r.get('statement'): + errors.append(f'{sid}/{rid}: missing statement') + # WHY: the statement becomes the acceptance deck's headline verbatim (feature-flow + # design §5), which enforces HEADLINE_MAX on ITS OWN pass — a long statement sails + # through contract-check, gets signed, then the acceptance deck refuses to build with + # an error naming a field the contract's author never wrote. Catch it here instead. + n = word_count(r.get('statement')) + if n > HEADLINE_MAX: + errors.append(f'{sid}/{rid}: statement is {n} words (max {HEADLINE_MAX}) — it becomes the acceptance deck\'s headline') + for k in ('statement', 'threshold', 'note'): + for w in banned_in(r.get(k)): + errors.append(f'{sid}/{rid}: {k} uses banned word "{w}"') + if r.get('checkedBy') not in CHECKED_BY: + errors.append(f'{sid}/{rid}: checkedBy must be one of {", ".join(CHECKED_BY)}') + if r.get('checkedBy') == 'mechanical' and not r.get('guard'): + errors.append(f'{sid}/{rid}: a mechanical row needs a guard (a workspace-relative test or script path)') + src = r.get('source') or '' + if not SOURCE_RE.match(src): + errors.append(f'{sid}/{rid}: source must look like #') + elif src.split('#')[0] not in sources: + errors.append(f'{sid}/{rid}: source deck "{src.split("#")[0]}" is not in the spec\'s "sources"') + if 'verdict' in r: + if r['verdict'] not in ('pass', 'fail'): + errors.append(f'{sid}/{rid}: verdict must be pass or fail') + if not r.get('evidence'): + errors.append(f'{sid}/{rid}: a verdict needs evidence (what was run or looked at)') + if not rows: + errors.append(f'{sid}: a contract with no rows defines nothing') + + def _validate_choice(spec, st, sid, errors, warnings): for k in ('surface', 'path', 'headline'): if not st.get(k): diff --git a/scripts/ui-review/review-cards.py b/scripts/ui-review/review-cards.py index 2b956218..ab351354 100644 --- a/scripts/ui-review/review-cards.py +++ b/scripts/ui-review/review-cards.py @@ -6,6 +6,12 @@ build it, serve it, open the browser, save answers to .answers.json, exit when Destin submits python3 scripts/ui-review/review-cards.py wait [--timeout MIN] block until the answers file says submitted (for a session that no longer holds the `serve` process) + python3 scripts/ui-review/review-cards.py contract-check .contract.json + every row's source resolves to an answered step in a submitted deck and every mechanical guard exists on disk or on + the contract's branch (exit 1 lists what doesn't); then reports, as ok:/todo: lines, whether the contract was signed + (its own answers file) and whether the acceptance deck was submitted + python3 scripts/ui-review/review-cards.py acceptance .contract.json + merge .contract.verdicts.json into .contract.acceptance.json — the contract graded, plus a yes/no per human row Five step kinds, each named by its own fields: APPROVE (`changed`+`notice`, yes/no), CHOICE (`variants` — a picture per option, pick one), DECIDE (`options` — one picture of @@ -14,8 +20,14 @@ `scripts/ui-review/record-pair.sh`, Before | After play side by side with a shared replay), and LIVE (`live` — panes of the RUNNING app he can hover, click and drag, one authored candidate each out of youcoded's compare/registry.tsx; `variants` makes it a pick-one, their -absence a yes/no, and `serve` boots the worktree's workbench for it). Wording-only questions -are not a step: ask in chat. +absence a yes/no, and `serve` boots the worktree's workbench for it). + +A step may instead be WORDS-ONLY ("words": true — a question with 1–3 written options, or a +statement to approve; no picture, no images folder needed): that is the questions deck asked +before anything is drawn. + +A CONTRACT step ("rows") is the definition of done signed off as one step; see +docs/active/specs/2026-09-01-feature-flow-design.md. Run `serve` in the background: its exit is the "review finished" signal and it prints the feedback summary. There is deliberately no separate crop step — a stale intermediate file drew @@ -23,11 +35,13 @@ docs/archive/specs/2026-08-27-review-deck-v2-design.md (§4–5). History of the three rejected formats before this one: docs/archive/handoffs/2026-08-27-review-deck-tooling-handoff.md.""" import argparse +import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from deck.build import build_page # noqa: E402 +from deck.contract import AcceptanceError, acceptance_spec, acceptance_status, check_contract, contract_steps, signoff # noqa: E402 from deck.crops import crop_images # noqa: E402 from deck.serve import already_served, serve, wait_for_submit # noqa: E402 from deck.spec import SpecError, load_spec, validate # noqa: E402 @@ -62,7 +76,7 @@ def main(argv): sys.stdout.reconfigure(line_buffering=True) ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest='cmd', required=True) - for c in ('build', 'serve', 'wait'): + for c in ('build', 'serve', 'wait', 'contract-check', 'acceptance'): sub.add_parser(c).add_argument('spec') for c in ('serve', 'wait'): sub.choices[c].add_argument('--timeout', type=float, default=240, help='minutes to wait for a submit (exit 2 after)') @@ -79,6 +93,55 @@ def main(argv): return build(spec) if a.cmd == 'wait': return wait_for_submit(spec, timeout_min=a.timeout) + if a.cmd == 'contract-check': + # Fix: check_contract() assumes a well-formed spec (a row with no `id` raised + # KeyError, which close-out.sh then printed under "contract does not hold" — a + # misleading error. Validate first, same as build(), so a malformed contract + # gets the real writing-rules message instead of a traceback. + errors, _ = validate(spec) + if errors: + print('\n'.join(errors), file=sys.stderr) + return 1 + if not contract_steps(spec): + print('no contract step in this spec (a step with "rows")', file=sys.stderr) + return 1 + problems = check_contract(spec) + if problems: + print('\n'.join(problems), file=sys.stderr) + return 1 + n = sum(len(st['rows']) for st in contract_steps(spec)) + # Three facts, three lines, `ok:`/`todo:` prefixed so close-out.sh can relay them + # without parsing anything. Only the first is an exit code (see contract.py). + print(f'ok: contract holds: {n} rows, every source answered and submitted, every guard found') + for ok, line in (signoff(spec), acceptance_status(spec)): + print(('ok: ' if ok else 'todo: ') + line) + return 0 + if a.cmd == 'acceptance': + # Fix: same as contract-check — a malformed contract must fail here with the + # writing-rules message, not a KeyError from acceptance_spec() later. + errors, _ = validate(spec) + if errors: + print('\n'.join(errors), file=sys.stderr) + return 1 + vpath = os.path.join(spec['_base'], spec['_stem'] + '.verdicts.json') + try: + with open(vpath) as f: + verdicts = json.load(f) + # Fix: OSError alone misses invalid JSON (a truncated/malformed verdicts file) — + # catch both and show the real cause instead of the generic "no file" guess. + except (OSError, ValueError) as e: + print(f'cannot read verdicts file at {vpath}: {e} — the grader writes {{rowId: {{verdict, evidence}}}} there first', file=sys.stderr) + return 1 + try: + acc = acceptance_spec(spec, verdicts) + except AcceptanceError as e: + print(str(e), file=sys.stderr) + return 1 + out = os.path.join(spec['_base'], spec['_stem'] + '.acceptance.json') + with open(out, 'w') as f: + json.dump(acc, f, indent=1) + print('wrote', out, '— now: review-cards.py serve', out) + return 0 # WHY the lock is checked before build(): a second `serve` of the same spec used to # rebuild the page and re-cut the crops out from under the running server, THEN exit 3. other = already_served(spec) diff --git a/scripts/ui-review/templates/contract.json b/scripts/ui-review/templates/contract.json new file mode 100644 index 00000000..0c919578 --- /dev/null +++ b/scripts/ui-review/templates/contract.json @@ -0,0 +1,36 @@ +{ + "title": " — contract", + "key": "", + "out": ".contract.html", + "themes": ["midnight"], + "branch": "", + "sources": { + "": "" + }, + "steps": [ + { + "id": "C", + "surface": "", + "path": "", + "headline": "This is what done means.", + "rows": [ + { + "id": "R1", + "statement": "", + "checkedBy": "human", + "threshold": "pass/fail", + "source": "#", + "note": "" + }, + { + "id": "R2", + "statement": "", + "checkedBy": "mechanical", + "guard": "", + "threshold": "", + "source": "#" + } + ] + } + ] +} diff --git a/scripts/ui-review/tests/close-out-contract.test.sh b/scripts/ui-review/tests/close-out-contract.test.sh new file mode 100755 index 00000000..d9e2564b --- /dev/null +++ b/scripts/ui-review/tests/close-out-contract.test.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# close-out.sh gets a Contract section: no contract → a note; a contract that holds but is +# unsigned with no acceptance deck → OK + TODO + TODO; signed and accepted → three OKs; a +# contract that does not hold → TODO with the problems indented. Runs against a temp docs dir. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; WS="$(cd "$HERE/../../.." && pwd)" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +python3 -c "import sys; sys.path.insert(0, '$HERE'); from fixture import contract_spec; print(contract_spec('$TMP'))" >/dev/null +X="$TMP/docs/active/design/x"; mkdir -p "$X" && mv "$TMP/deck/"* "$X/" + +out=$(CLOSE_OUT_DOCS="$TMP/nothing" bash "$WS/scripts/close-out.sh" no-such-branch-zz workspace) +grep -q "^Contract" <<<"$out" || { echo "no Contract section"; exit 1; } +grep -q "no contract names this branch" <<<"$out" || { echo "missing 'no contract' note"; echo "$out"; exit 1; } + +# pass()/fail() print colour escapes between the OK/TODO word and the message, so match loosely. +out=$(CLOSE_OUT_DOCS="$TMP/docs" bash "$WS/scripts/close-out.sh" feat/arcade-fixture workspace) +grep -q "OK.*contract holds: 3 rows" <<<"$out" || { echo "expected 'contract holds'"; echo "$out"; exit 1; } +grep -q "TODO.*not signed" <<<"$out" || { echo "expected unsigned TODO"; echo "$out"; exit 1; } +grep -q "TODO.*acceptance deck not built" <<<"$out" || { echo "expected acceptance TODO"; echo "$out"; exit 1; } + +echo '{"submitted":"2026-09-01T11:00:00Z","answers":{"C":{"v":"yes"}}}' > "$X/arcade.contract.answers.json" +echo '{"key":"arcade-contract-acceptance","steps":[]}' > "$X/arcade.contract.acceptance.json" +echo '{"submitted":"2026-09-01T12:00:00Z","answers":{"C":{"v":"yes"},"R2":{"v":"yes"}}}' > "$X/arcade.contract.acceptance.answers.json" +out=$(CLOSE_OUT_DOCS="$TMP/docs" bash "$WS/scripts/close-out.sh" feat/arcade-fixture workspace) +grep -q "OK.*signed 2026-09-01 11:00" <<<"$out" || { echo "expected signed OK"; echo "$out"; exit 1; } +grep -q "OK.*acceptance deck submitted" <<<"$out" || { echo "expected acceptance OK"; echo "$out"; exit 1; } + +python3 - "$X/r1.answers.json" <<'PY' +import json, sys; p = sys.argv[1]; a = json.load(open(p)); a['submitted'] = None; json.dump(a, open(p, 'w')) +PY +out=$(CLOSE_OUT_DOCS="$TMP/docs" bash "$WS/scripts/close-out.sh" feat/arcade-fixture workspace) +grep -q "TODO.*contract does not hold" <<<"$out" || { echo "expected does-not-hold TODO"; echo "$out"; exit 1; } +grep -q "never submitted" <<<"$out" || { echo "expected the problem line"; echo "$out"; exit 1; } +echo "close-out contract section: ok" diff --git a/scripts/ui-review/tests/deck-render.test.mjs b/scripts/ui-review/tests/deck-render.test.mjs index 06e71cc0..ad8c469b 100644 --- a/scripts/ui-review/tests/deck-render.test.mjs +++ b/scripts/ui-review/tests/deck-render.test.mjs @@ -241,3 +241,50 @@ test('live step: a stopped app server says so, with the command that starts it', assert.equal(await c.evaluate("document.querySelectorAll('#inner iframe').length"), 0, 'no dead iframes left behind'); } finally { c.close(); if (srv.exitCode === null) srv.kill(); } }); + +test('a words-only deck renders with no stage and records a pick', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'deck-words-')); + const fx = spawnSync('python3', ['-c', `import sys; sys.path.insert(0, ${JSON.stringify(HERE)}); from fixture import words_spec; print(words_spec(${JSON.stringify(tmp)}))`], { encoding: 'utf8' }); + const spec = fx.stdout.trim(); assert.ok(spec.endsWith('questions.json'), fx.stderr); + { const r = spawnSync('python3', [RC, 'build', spec], { encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); } + const port = await freePort(); + const srv = spawn('python3', [RC, 'serve', spec, '--no-open', '--no-build', '--port', String(port), '--timeout', '2'], { stdio: ['ignore', 'pipe', 'pipe'] }); + try { + await sleep(800); + const c = await cdp(await freePort(), 1400, 900); + try { + await c.send('Page.navigate', { url: `http://127.0.0.1:${port}/questions.html` }); + for (let i = 0; i < 40 && !(await c.evaluate('window.__deckReady === true')); i++) await sleep(100); + assert.equal(await c.evaluate('document.body.dataset.layout'), 'words'); + assert.equal(await c.evaluate("getComputedStyle(document.querySelector('#stage')).display"), 'none'); + assert.equal(await c.evaluate("document.querySelectorAll('.card.option').length"), 1); // Q-1: one option + assert.equal(await c.evaluate("[...document.querySelectorAll('.ans')].map(b => b.dataset.v).join(',')"), 'other'); + await c.evaluate("document.querySelector('.card.option').click()"); + await c.evaluate("document.querySelector('#save').click()"); // → Q-2 + // Back up to Q-1 and tag its note — proves the tag row appears once the note has text + // and the pick lands next to it in the saved file (feature-flow design §5). + await c.evaluate("document.querySelector('#prev').click()"); // back to Q-1 + await c.evaluate("const n = document.querySelector('#note'); n.value = 'smaller'; n.dispatchEvent(new Event('input'))"); + assert.equal(await c.evaluate("document.querySelector('#tags').hidden"), false); + await c.evaluate("document.querySelector('.tag[data-kind=later]').click()"); + await c.evaluate("document.querySelector('#save').click()"); // → Q-2 again + await c.evaluate("document.querySelector('#next').click()"); // → Q-3 + await sleep(200); + assert.equal(await c.evaluate("[...document.querySelectorAll('.ans')].map(b => b.textContent).join(',')"), 'Holds,Fails,Other'); + await sleep(400); + const answers = JSON.parse(readFileSync(spec.replace(/\.json$/, '.answers.json'), 'utf8')); + assert.deepEqual([answers.answers['Q-1'].v, answers.answers['Q-1'].pick], ['pick', 'a']); + assert.equal(answers.answers['Q-1'].note_kind, 'later'); + // Clearing the note must remove its tag — paintState()'s highlight is display-only and + // must not repaint a tag that isn't stored (the fix this test pins). + await c.evaluate("document.querySelectorAll('#steps span')[0].click()"); // back to Q-1 + await c.evaluate("const n2 = document.querySelector('#note'); n2.value = ''; n2.dispatchEvent(new Event('input'))"); + assert.equal(await c.evaluate("document.querySelector('#tags').hidden"), true); + await c.evaluate("document.querySelector('#save').click()"); // → Q-2, saves the cleared note + await sleep(400); + const answers2 = JSON.parse(readFileSync(spec.replace(/\.json$/, '.answers.json'), 'utf8')); + assert.equal('note_kind' in answers2.answers['Q-1'], false); + assert.deepEqual(c.errors, []); + } finally { c.close(); } + } finally { srv.kill(); } +}); diff --git a/scripts/ui-review/tests/fixture.py b/scripts/ui-review/tests/fixture.py index 932de3f9..6a9c4056 100644 --- a/scripts/ui-review/tests/fixture.py +++ b/scripts/ui-review/tests/fixture.py @@ -145,3 +145,89 @@ def live_spec(tmp, base=None, **over): with open(p, 'w') as f: json.dump(spec, f, indent=1) return p + + +# ── words-only decks ──────────────────────────────────────────────────────────────────── +def words_spec(tmp, **over): + """A QUESTIONS deck: no pictures anywhere. One question with a single option (plus the + page's own Other), one with three, and one statement to approve with relabelled buttons. + Picture-free on purpose, like live_spec — this is CI coverage.""" + deck = os.path.join(tmp, 'deck') + os.makedirs(deck, exist_ok=True) + spec = { + 'title': 'Questions fixture', 'key': 'questions-fixture', 'out': 'questions.html', + 'themes': ['midnight', 'light'], + 'steps': [ + {'id': 'Q-1', 'words': True, 'surface': 'Games', 'path': 'Questions', + 'headline': 'Where does the invite live?', + 'options': [{'id': 'a', 'label': 'In the friends list (recommended)', 'summary': 'One place for everything about a friend.'}]}, + {'id': 'Q-2', 'words': True, 'surface': 'Games', 'path': 'Questions', + 'headline': 'How many boards on screen at once?', + 'options': [{'id': 'a', 'label': 'One', 'summary': 'Simplest.'}, + {'id': 'b', 'label': 'Two', 'summary': 'Mine and theirs.'}, + {'id': 'c', 'label': 'As many as fit', 'summary': 'Costs a layout rule.'}]}, + {'id': 'Q-3', 'words': True, 'surface': 'Games', 'path': 'Questions', + 'headline': 'A game you leave keeps running for the other player.', + 'changed': 'Stated, not asked: the alternative would surprise the friend who stayed.', + 'notice': 'Nothing yet — this becomes a row of the contract.', + 'yes': 'Holds', 'no': 'Fails'}, + ], + } + spec.update(over) + p = os.path.join(deck, 'questions.json') + with open(p, 'w') as f: + json.dump(spec, f, indent=1) + return p + + +# ── contract ──────────────────────────────────────────────────────────────────────────── +def contract_spec(tmp, **over): + """A contract deck plus the two source decks its rows point at, each with a SUBMITTED + answers file — so contract-check has something real to resolve. Picture-free.""" + deck = os.path.join(tmp, 'deck') + os.makedirs(deck, exist_ok=True) + # Source deck 1: a words question, answered. Source deck 2: a picture step, answered. + q = {'title': 'Q', 'key': 'arcade-questions', 'out': 'q.html', 'themes': ['midnight'], + 'steps': [{'id': 'Q-1', 'words': True, 'surface': 'Games', 'path': 'Questions', 'headline': 'Where does the invite live?', + 'options': [{'id': 'a', 'label': 'Friends list', 'summary': 'One place.'}]}]} + r1 = {'title': 'R1', 'key': 'arcade-r1', 'out': 'r1.html', 'images': 'images/r1', 'runs': {'today': '/nowhere'}, + 'crops': {'c': ['main', 'home', '10x10+0+0']}, + 'steps': [{'id': 'S-1', 'surface': 'Board', 'path': 'Games', 'crop': 'c', 'highlight': {'text': 'Send'}, + 'headline': 'Boards are told apart.', 'changed': 'A colour band.', 'notice': 'Two boards.'}, + {'id': 'S-2', 'surface': 'Board', 'path': 'Games', 'crop': 'c', 'highlight': {'text': 'Send'}, + 'headline': 'Skipped one.', 'changed': 'x', 'notice': 'y'}]} + for name, s in (('q', q), ('r1', r1)): + with open(os.path.join(deck, f'{name}.json'), 'w') as f: + json.dump(s, f, indent=1) + with open(os.path.join(deck, 'q.answers.json'), 'w') as f: + json.dump({'deck': 'arcade-questions', 'submitted': '2026-09-01T09:00:00Z', + 'answers': {'Q-1': {'v': 'pick', 'pick': 'a', 'seconds': 12}}}, f) + with open(os.path.join(deck, 'r1.answers.json'), 'w') as f: + json.dump({'deck': 'arcade-r1', 'submitted': '2026-09-01T09:30:00Z', + 'answers': {'S-1': {'v': 'yes', 'note': 'band could be thinner', 'note_kind': 'later', 'seconds': 20}, + 'S-2': {'v': 'skip', 'seconds': 1}}}, f) + spec = { + # Fix: `out` must share the contract's stem (arcade.contract.html, not contract.html) — + # two contracts in one folder would otherwise overwrite each other's built page. + 'title': 'Arcade — contract', 'key': 'arcade-contract', 'out': 'arcade.contract.html', 'themes': ['midnight'], + 'branch': 'feat/arcade-fixture', + 'sources': {'arcade-questions': 'q.json', 'arcade-r1': 'r1.json'}, + 'steps': [{'id': 'C', 'surface': 'Games arcade', 'path': 'Contract', 'headline': 'This is what done means.', + 'rows': [ + {'id': 'R1', 'statement': 'The invite lives in the friends list.', 'checkedBy': 'deck', + 'threshold': 'pass/fail', 'source': 'arcade-questions#Q-1'}, + {'id': 'R2', 'statement': "A second player's board is tellable from mine at a glance.", + 'checkedBy': 'human', 'threshold': 'pass/fail', 'source': 'arcade-r1#S-1', 'note': 'band could be thinner'}, + # The guard must exist under workspace_root() — which from a WORKTREE is the main + # checkout, so it has to be a file already on master, not one this branch adds. + {'id': 'R3', 'statement': 'The board fills the pane at every width.', 'checkedBy': 'mechanical', + 'guard': 'scripts/ui-review/tests/test_spec.py', 'threshold': 'the named test passes', + 'source': 'arcade-r1#S-1'}, + ]}], + } + spec.update(over) + # `.contract.json` — the `.contract` in the stem is what close-out.sh globs for. + p = os.path.join(deck, 'arcade.contract.json') + with open(p, 'w') as f: + json.dump(spec, f, indent=1) + return p diff --git a/scripts/ui-review/tests/test_contract.py b/scripts/ui-review/tests/test_contract.py new file mode 100644 index 00000000..baa5b3ac --- /dev/null +++ b/scripts/ui-review/tests/test_contract.py @@ -0,0 +1,293 @@ +"""The contract step (rows Destin signs off) and, from Task 4, contract-check + the acceptance +deck. Picture-free like test_live.py. Design: docs/active/specs/2026-09-01-feature-flow-design.md §3–§7.""" +import json +import os +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(HERE)) +sys.path.insert(0, HERE) +from fixture import contract_spec # noqa: E402 +from deck.build import build_page, deck_data # noqa: E402 +from deck.spec import is_contract, load_spec, no_pictures, validate # noqa: E402 + + +# Fix: bare `json.dump(a, open(ap, 'w'))` / `json.load(open(ap))` leave the file handle open +# until GC — 13 ResourceWarnings across this file's tests. These two helpers are the only way +# the tests below read or write a JSON fixture file. +def read_json(p): + with open(p) as f: + return json.load(f) + + +def write_json(p, obj): + with open(p, 'w') as f: + json.dump(obj, f) + + +def spec_with(tmp, mutate, **over): + p = contract_spec(tmp, **over) + with open(p) as f: + raw = json.load(f) + mutate(raw) + with open(p, 'w') as f: + json.dump(raw, f) + return load_spec(p) + + +def errs(spec): + return validate(spec)[0] + + +class ContractStepTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_valid_contract_has_no_errors_and_no_pictures(self): + s = load_spec(contract_spec(self.tmp)) + self.assertTrue(is_contract(s['steps'][0])); self.assertTrue(no_pictures(s)) + self.assertEqual(errs(s), []) + + def test_row_fields(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][0].update({'checkedBy': 'vibes', 'source': 'nohash'})) + e = errs(s) + self.assertTrue(any('C/R1: checkedBy must be one of mechanical, deck, live-app, human' in x for x in e)) + self.assertTrue(any('C/R1: source must look like #' in x for x in e)) + + def test_mechanical_needs_a_guard(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][2].pop('guard')) + self.assertTrue(any('C/R3: a mechanical row needs a guard' in x for x in errs(s))) + + def test_source_key_must_be_in_sources(self): + s = spec_with(self.tmp, lambda r: r['sources'].pop('arcade-r1')) + self.assertTrue(any('C/R2: source deck "arcade-r1" is not in the spec\'s "sources"' in x for x in errs(s))) + + def test_verdict_needs_evidence(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][0].update({'verdict': 'pass'})) + self.assertTrue(any('C/R1: a verdict needs evidence' in x for x in errs(s))) + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][0].update({'verdict': 'maybe', 'evidence': 'x'})) + self.assertTrue(any('C/R1: verdict must be pass or fail' in x for x in errs(s))) + + def test_statement_obeys_writing_rules(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][0].update({'statement': 'The reducer stores it.'})) + self.assertTrue(any('C/R1: statement uses banned word "reducer"' in x for x in errs(s))) + + def test_statement_too_long_names_the_acceptance_deck_headline(self): + # WHY: a long statement used to pass contract-check, then break the acceptance deck + # later with an error naming a field the contract's author never wrote (spec.py fix). + long_statement = ' '.join(['word'] * 30) + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][0].update({'statement': long_statement})) + e = errs(s) + self.assertTrue(any('C/R1: statement is 30 words (max 25)' in x and 'acceptance deck' in x for x in e), e) + + def test_duplicate_row_ids(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][1].update({'id': 'R1'})) + self.assertTrue(any('C: duplicate row id "R1"' in x for x in errs(s))) + + def test_empty_rows_is_the_contract_error_not_a_missing_crop(self): + s = spec_with(self.tmp, lambda r: r['steps'][0].update({'rows': []})) + e = errs(s) + self.assertTrue(any('C: a contract with no rows defines nothing' in x for x in e), e) + self.assertFalse(any('missing crop' in x for x in e), e) + + def test_contract_refuses_options(self): + s = spec_with(self.tmp, lambda r: r['steps'][0].update({'options': [{'id': 'a', 'label': 'x', 'summary': 'y'}]})) + self.assertTrue(any('C: a contract step has no options' in x for x in errs(s))) + + def test_deck_data_and_page(self): + s = load_spec(contract_spec(self.tmp)) + st = deck_data(s, {})['steps'][0] + self.assertEqual((st['kind'], st['words']), ('contract', True)) + self.assertEqual([r['id'] for r in st['rows']], ['R1', 'R2', 'R3']) + self.assertEqual((st['yes'], st['no']), ('Yes, that is done', 'No, something is missing')) + self.assertNotIn('options', st) + page, _ = build_page(s, {}) + self.assertIn('"kind": "contract"', page) + + +class ContractCheckTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_fixture_contract_holds(self): + from deck.contract import check_contract + s = load_spec(contract_spec(self.tmp)) + self.assertEqual(check_contract(s), []) + + def test_unsubmitted_source_is_reported(self): + from deck.contract import check_contract + p = contract_spec(self.tmp) + ap = os.path.join(os.path.dirname(p), 'r1.answers.json') + a = read_json(ap); a['submitted'] = None; write_json(ap, a) + problems = check_contract(load_spec(p)) + self.assertTrue(any('R2: r1.json answers were never submitted' in x for x in problems), problems) + + def test_rotated_answers_are_found(self): + # serve re-run after a submit moves the file to .answers..json (serve.rotate_submitted); + # the check reads the newest SUBMITTED file, whichever name it carries. + from deck.contract import check_contract + p = contract_spec(self.tmp); d = os.path.dirname(p) + os.replace(os.path.join(d, 'r1.answers.json'), os.path.join(d, 'r1.answers.202609010930.json')) + write_json(os.path.join(d, 'r1.answers.json'), {'deck': 'arcade-r1', 'submitted': None, 'answers': {}}) + self.assertEqual(check_contract(load_spec(p)), []) + + def test_skipped_step_is_not_a_source(self): + from deck.contract import check_contract + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][1].update({'source': 'arcade-r1#S-2'})) + self.assertTrue(any('R2: step S-2 of arcade-r1 was not answered' in x for x in check_contract(s))) + + def test_unknown_step_and_missing_guard(self): + from deck.contract import check_contract + s = spec_with(self.tmp, lambda r: (r['steps'][0]['rows'][0].update({'source': 'arcade-r1#S-9'}), + r['steps'][0]['rows'][2].update({'guard': 'scripts/nope.py'}))) + problems = check_contract(s) + self.assertTrue(any('R1: no step "S-9" in r1.json' in x for x in problems), problems) + # WHY this text, not the brief's "does not exist": check_contract's actual message (matching + # design §4 and test_guard_committed_on_the_branch_counts below) always names BOTH places a + # guard is looked for — disk and the contract's branch — so a plain "does not exist" never + # appears; the brief's literal test text was stale against its own contract.py. + self.assertTrue(any('R3: guard scripts/nope.py is neither on disk under' in x for x in problems), problems) + + def test_guard_committed_on_the_branch_counts(self): + # A feature's mechanical rows mostly name tests the feature ADDS. From a worktree the + # workspace root is the main checkout, where that file does not exist until merge — so + # the check also looks on the contract's branch. Uncommitted still does not count. + import subprocess + from unittest import mock + from deck.contract import check_contract, guard_exists + root = os.path.join(self.tmp, 'ws'); os.makedirs(os.path.join(root, 'scripts')) + g = lambda *a: subprocess.run(['git', '-C', root, *a], check=True, capture_output=True, text=True) + g('init', '-q', '-b', 'main'); g('config', 'user.email', 't@t'); g('config', 'user.name', 't') + with open(os.path.join(root, 'README'), 'w') as f: + f.write('x') + g('add', 'README'); g('commit', '-qm', 'base') + g('checkout', '-qb', 'feat/x') + with open(os.path.join(root, 'scripts', 'guard.py'), 'w') as f: + f.write('# guard') + g('add', 'scripts/guard.py'); g('commit', '-qm', 'guard') + g('checkout', '-q', 'main') # back on main: the guard is NOT on disk + self.assertFalse(os.path.exists(os.path.join(root, 'scripts', 'guard.py'))) + self.assertTrue(guard_exists(root, 'feat/x', 'scripts/guard.py')) + self.assertFalse(guard_exists(root, 'main', 'scripts/guard.py')) + self.assertFalse(guard_exists(root, 'feat/x', 'scripts/uncommitted.py')) + with mock.patch.dict(os.environ, {'YOUCODED_WORKSPACE': root}): + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][2].update({'guard': 'scripts/guard.py'}), branch='feat/x') + self.assertEqual(check_contract(s), []) + s = spec_with(self.tmp, lambda r: r['steps'][0]['rows'][2].update({'guard': 'scripts/guard.py'}), branch='main') + self.assertTrue(any('R3: guard scripts/guard.py is neither on disk under' in x for x in check_contract(s))) + + def test_signoff_is_the_contracts_own_answer(self): + from deck.contract import signoff + p = contract_spec(self.tmp); s = load_spec(p) + ok, line = signoff(s) + self.assertFalse(ok); self.assertIn('not signed', line) + ap = p.replace('.json', '.answers.json') + write_json(ap, {'deck': 'arcade-contract', 'submitted': None, 'answers': {'C': {'v': 'yes'}}}) + ok, line = signoff(s) + self.assertFalse(ok); self.assertIn('not signed', line) # answered but never submitted + write_json(ap, {'deck': 'arcade-contract', 'submitted': '2026-09-01T11:00:00Z', 'answers': {'C': {'v': 'no', 'note': 'R2 is wrong'}}}) + ok, line = signoff(s) + self.assertFalse(ok); self.assertIn('answered "no"', line); self.assertIn('R2 is wrong', line) + write_json(ap, {'deck': 'arcade-contract', 'submitted': '2026-09-01T11:00:00Z', 'answers': {'C': {'v': 'yes'}}}) + ok, line = signoff(s) + self.assertTrue(ok); self.assertIn('signed 2026-09-01 11:00', line) + + def test_acceptance_status(self): + from deck.contract import acceptance_status + p = contract_spec(self.tmp); s = load_spec(p); d = os.path.dirname(p) + acc_path = os.path.join(d, 'arcade.contract.acceptance.json') + ok, line = acceptance_status(s) + self.assertFalse(ok); self.assertIn('acceptance deck not built', line) + # A corrupt acceptance file is a different problem than "built but not yet submitted" — + # the message must name the real cause instead of assuming the file was never touched. + with open(acc_path, 'w') as f: + f.write('{not valid json') + ok, line = acceptance_status(s) + self.assertFalse(ok); self.assertIn('unreadable', line) + write_json(acc_path, {'key': 'x', 'steps': []}) + ok, line = acceptance_status(s) + self.assertFalse(ok); self.assertIn('acceptance deck not submitted', line) + write_json(os.path.join(d, 'arcade.contract.acceptance.answers.json'), {'submitted': '2026-09-01T12:00:00Z', 'answers': {'C': {'v': 'yes'}}}) + ok, line = acceptance_status(s) + self.assertTrue(ok); self.assertIn('acceptance deck submitted 2026-09-01 12:00', line) + + def test_cli_contract_check(self): + import importlib.util + spec_ = importlib.util.spec_from_file_location('review_cards', os.path.join(os.path.dirname(HERE), 'review-cards.py')) + rc = importlib.util.module_from_spec(spec_); spec_.loader.exec_module(rc) + import io + from contextlib import redirect_stderr, redirect_stdout + p = contract_spec(self.tmp) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = rc.main(['contract-check', p]) + self.assertEqual(code, 0, err.getvalue()) + lines = out.getvalue().splitlines() + self.assertTrue(lines[0].startswith('ok: contract holds: 3 rows'), lines) + self.assertTrue(lines[1].startswith('todo: not signed'), lines) + self.assertTrue(lines[2].startswith('todo: acceptance deck not built'), lines) + # A source problem is exit 1 with the problems on stderr and nothing on stdout. + ap = os.path.join(os.path.dirname(p), 'r1.answers.json') + a = read_json(ap); a['submitted'] = None; write_json(ap, a) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = rc.main(['contract-check', p]) + self.assertEqual(code, 1); self.assertIn('never submitted', err.getvalue()); self.assertEqual(out.getvalue(), '') + + def test_cli_contract_check_malformed_contract_is_not_a_traceback(self): + # Fix: a row with no `id` used to reach check_contract() (which assumes a well-formed + # spec) and raise KeyError — close-out.sh printed that under "contract does not hold", + # a misleading error. contract-check now validates first, same as build(). + import importlib.util + spec_ = importlib.util.spec_from_file_location('review_cards', os.path.join(os.path.dirname(HERE), 'review-cards.py')) + rc = importlib.util.module_from_spec(spec_); spec_.loader.exec_module(rc) + import io + from contextlib import redirect_stderr, redirect_stdout + p = contract_spec(self.tmp) + with open(p) as f: + raw = json.load(f) + del raw['steps'][0]['rows'][0]['id'] + with open(p, 'w') as f: + json.dump(raw, f) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = rc.main(['contract-check', p]) + self.assertEqual(code, 1) + self.assertIn('has no id', err.getvalue()) + self.assertNotIn('Traceback', err.getvalue()) + self.assertEqual(out.getvalue(), '') + + +class AcceptanceTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_refuses_without_verdicts_for_graded_rows(self): + from deck.contract import acceptance_spec, AcceptanceError + s = load_spec(contract_spec(self.tmp)) + with self.assertRaises(AcceptanceError) as cm: + acceptance_spec(s, {'R1': {'verdict': 'pass', 'evidence': 'answered a'}}) + self.assertIn('R3 (mechanical) has no verdict', str(cm.exception)) + + def test_builds_the_acceptance_deck(self): + from deck.contract import acceptance_spec + s = load_spec(contract_spec(self.tmp)) + acc = acceptance_spec(s, {'R1': {'verdict': 'pass', 'evidence': 'answered a'}, + 'R3': {'verdict': 'fail', 'evidence': 'test_contract.py: 1 failed'}}) + self.assertEqual(acc['key'], 'arcade-contract-acceptance') + self.assertEqual([st['id'] for st in acc['steps']], ['C', 'R2']) + c, r2 = acc['steps'] + self.assertEqual([r.get('verdict') for r in c['rows']], ['pass', None, 'fail']) + self.assertTrue(r2['words']); self.assertEqual((r2['yes'], r2['no']), ('Holds', 'Fails')) + self.assertEqual(r2['headline'], "A second player's board is tellable from mine at a glance.") + self.assertIn('band could be thinner', r2['changed']) + # It is itself a valid deck. + d = os.path.dirname(contract_spec(self.tmp)) + ap = os.path.join(d, 'arcade.contract.acceptance.json'); write_json(ap, acc) + self.assertEqual(validate(load_spec(ap))[0], []) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/ui-review/tests/test_words.py b/scripts/ui-review/tests/test_words.py new file mode 100644 index 00000000..dbb65c45 --- /dev/null +++ b/scripts/ui-review/tests/test_words.py @@ -0,0 +1,112 @@ +"""Words-only steps: a question or a statement with NO picture. Validation, the data the page +gets, and the runs/images rule. Picture-free like test_live.py — this is what CI runs. +Plan: docs/active/plans/2026-09-01-feature-flow-plan.md Task 1.""" +import json +import os +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(HERE)) +sys.path.insert(0, HERE) +from fixture import words_spec # noqa: E402 +from deck.build import build_page, deck_data # noqa: E402 +from deck.crops import crop_images # noqa: E402 +from deck.spec import SpecError, is_words, load_spec, no_pictures, validate # noqa: E402 + + +def spec_with(tmp, mutate, **over): + p = words_spec(tmp, **over) + with open(p) as f: + raw = json.load(f) + mutate(raw) + with open(p, 'w') as f: + json.dump(raw, f) + return load_spec(p) + + +def errs(spec): + return validate(spec)[0] + + +class WordsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_words_deck_needs_no_images_or_runs(self): + s = load_spec(words_spec(self.tmp)) + self.assertTrue(no_pictures(s)) + self.assertNotIn('images', s) + self.assertEqual(list(s['runs']), ['today']) + self.assertEqual(errs(s), []) + + def test_one_option_is_enough_without_a_picture(self): + s = load_spec(words_spec(self.tmp)) + self.assertEqual([e for e in errs(s) if 'Q-1' in e], []) + + def test_a_picture_decide_still_needs_two_options(self): + # The two-option floor stays for picture decks: one option plus Other is a yes/no in disguise. + s = spec_with(self.tmp, lambda r: r['steps'].append( + {'id': 'D-1', 'surface': 'Games', 'path': 'Board', 'crop': 'bubble', 'highlight': {'text': 'Send'}, + 'headline': 'Bigger?', 'options': [{'id': 'a', 'label': 'Yes', 'summary': 'x'}]}), + images='images/questions', runs={'today': '/nowhere'}) + self.assertTrue(any('D-1: a decide step needs at least 2 options' in e for e in errs(s))) + + def test_words_step_refuses_a_picture(self): + s = spec_with(self.tmp, lambda r: r['steps'][0].update({'crop': 'bubble'})) + self.assertTrue(any('Q-1: a words step has no crop' in e for e in errs(s))) + + def test_words_statement_needs_its_body(self): + s = spec_with(self.tmp, lambda r: r['steps'][2].pop('notice')) + self.assertTrue(any('Q-3: missing notice' in e for e in errs(s))) + + def test_words_step_obeys_the_writing_rules(self): + s = spec_with(self.tmp, lambda r: r['steps'][0]['options'][0].update({'summary': 'Uses a new reducer'})) + self.assertTrue(any('banned word "reducer"' in e for e in errs(s))) + + def test_words_step_yes_no_labels_obey_the_writing_rules(self): + # The yes/no labels are button copy Destin reads — the same banned-word rule + # that already covers headline/options/etc must reach them too. + s = spec_with(self.tmp, lambda r: r['steps'][2].update({'yes': 'Uses the reducer'})) + self.assertTrue(any('Q-3: yes label uses banned word "reducer"' in e for e in errs(s))) + + def test_deck_data_marks_words_and_carries_labels(self): + s = load_spec(words_spec(self.tmp)) + d = deck_data(s, {}) + q1, q3 = d['steps'][0], d['steps'][2] + self.assertTrue(q1['words'] and q3['words']) + self.assertEqual(q1['kind'], 'decide'); self.assertEqual(len(q1['options']), 1) + self.assertNotIn('images', q1); self.assertNotIn('boxes', q1) + self.assertEqual((q3['yes'], q3['no']), ('Holds', 'Fails')) + self.assertNotIn('kind', q3) + + def test_crop_and_build_skip_words_steps(self): + s = load_spec(words_spec(self.tmp)) + r = crop_images(s, log=lambda m: None) + self.assertEqual((r['count'], r['missing']), (0, [])) + page, warnings = build_page(s, r['boxes']) + self.assertIn('"words": true', page) + self.assertEqual(warnings, []) + + def test_summary_names_the_note_tag(self): + from deck.serve import summary + s = load_spec(words_spec(self.tmp)) + state = {'submitted': '2026-09-01T10:00:00Z', 'answers': { + 'Q-1': {'v': 'pick', 'pick': 'a', 'note': 'but smaller', 'note_kind': 'now'}, + 'Q-3': {'v': 'yes', 'note': 'fine', 'note_kind': 'noting'}}} + lines = summary(s, state).split('\n') + self.assertEqual(lines[1], 'Q-1 pick a — "but smaller" [fix now]') + self.assertEqual(lines[3], 'Q-3 yes — "fine" [just noting]') + + def test_is_words_is_the_flag_not_a_guess(self): + # A step that merely FORGOT its crop is still an error, not a silent words step. + self.assertFalse(is_words({'id': 'x', 'headline': 'h'})) + self.assertTrue(is_words({'id': 'x', 'words': True})) + # A contract step (rows) is a words step too — even with no rows yet, so the empty + # contract gets the contract error in Task 3, not "missing crop". + self.assertTrue(is_words({'id': 'x', 'rows': []})) + + +if __name__ == '__main__': + unittest.main()