From ad10e6aa674f3058a20d1e64a3b2143a9e527933 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 13:42:49 -0700 Subject: [PATCH 01/24] docs(feature-flow): design rewritten clean after review; implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review against origin/master bc2e656 found one defect the design rests on — every *.answers.json is gitignored (.gitignore:97-98), so the contract's sources had no history — plus stale positioning (taxonomy draft archived, /wrap-up and ui-probe landed, restructure design supersedes) and a fourth appearance (the reopen deck) the count missed. Decided: the contract IS the acceptance deck's spec (one format); questions deck = words-only decide steps; note tags; the plan tier and reopen default are stated as assumptions for Destin to veto (§9). The plan: 8 tasks, all inside scripts/ui-review + close-out.sh + docs; new tests picture-free so CI runs them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2 --- .../plans/2026-09-01-feature-flow-plan.md | 1547 +++++++++++++++++ .../plans/2026-09-01-feature-flow-redesign.md | 131 -- .../specs/2026-09-01-feature-flow-design.md | 142 ++ 3 files changed, 1689 insertions(+), 131 deletions(-) create mode 100644 docs/active/plans/2026-09-01-feature-flow-plan.md delete mode 100644 docs/active/plans/2026-09-01-feature-flow-redesign.md create mode 100644 docs/active/specs/2026-09-01-feature-flow-design.md 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..c7f28028 --- /dev/null +++ b/docs/active/plans/2026-09-01-feature-flow-plan.md @@ -0,0 +1,1547 @@ +--- +status: draft +created: 2026-09-01 +type: plan +spec: docs/active/specs/2026-09-01-feature-flow-design.md +measured_at: + youcoded-dev: bc2e656 (origin/master) +--- + +# 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/`). Two new step shapes — *words-only* (a step with no picture; `"words": true`) and *contract* (a step with `rows`) — 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: `docs/feature-flow-plan` in worktree `worktrees/feature-flow` (already exists, holds the spec and this plan). Code tasks continue on the same branch. + +--- + +### Task 0: Track answers files under `docs/` + +**Files:** +- Modify: `.gitignore:97-98` +- 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: Narrow the ignore to the scratch folder** + +Replace lines 97–98 of `.gitignore`: + +``` +*.answers.json +*.answers.*.json +``` + +with: + +``` +# Deck answers under docs/ are Destin's decisions and are COMMITTED (feature-flow design §2). +# Only throwaway decks in scratch/ stay untracked. `*.serve.json` below is a runtime lock. +scratch/**/*.answers.json +scratch/**/*.answers.*.json +``` + +- [ ] **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 scratch-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 scratch/probe.answers.json +``` +Expected: `docs rc=1` (not ignored), `scratch rc=0` with the `.gitignore:` 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/ stays ignored. + +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 59; `validate` dispatch ~line 174; `_validate_decide` lines 218–259; new `is_words`, `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})) + + +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 110) add: + +```python +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); without, a statement to approve (`changed` + `notice` are its body). Two users: + the QUESTIONS deck answered before anything is drawn, and the acceptance deck's + human rows (feature-flow design §5, §7).""" + return step.get('words') is True + + +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. Always words-only. Validation and the page + come in Task 3 of the plan; the predicate lives here so no_pictures() is written once.""" + return bool(step.get('rows')) + + +def no_pictures(spec): + """A deck with no picture steps at all — every step is live, words-only or a contract. + 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) or is_contract(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 lines 233–256 (from `opts = st['options']` through the `measured has no number` warning) 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.""" + 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 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 42) with `if no_pictures(spec):`, drop `all_live` from the `.live` import if nothing else uses it there, and after the `if is_live(st): continue` in the loop add: + +```python + if is_words(st) or is_contract(st): + continue # words only — nothing to cut, no `crop` to look up +``` + +(import `is_contract` from `.spec` alongside `is_words`.) + +- [ ] **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 +``` + +- [ ] **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` (`is_contract`, `_validate_contract`, dispatch, constants) +- Modify: `scripts/ui-review/deck/build.py` (`_contract_step`, dispatch) +- 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: `is_contract(step) -> bool` (`bool(step.get('rows'))`); `CHECKED_BY = ('mechanical', 'deck', 'live-app', 'human')`; deck data `{'id', 'kind': 'contract', 'words': True, 'surface', 'path', 'headline', 'notice', 'risk', 'rows': [{'id','statement','checkedBy','guard','threshold','source','note','verdict','evidence'}], 'yes', 'no'}`; `fixture.contract_spec(tmp, **over) -> path` which also writes two source decks with **submitted** answers files beside it. +- 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_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')) + 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). Add near the other constants: + +```python +CHECKED_BY = ('mechanical', 'deck', 'live-app', 'human') +SOURCE_RE = re.compile(r'^[\w.-]+#[\w.-]+$') +``` + +In `validate`, before the `is_words` dispatch from Task 1: + +```python + if is_contract(st): + _validate_contract(spec, st, sid, errors, warnings) + continue +``` + +Add: + +```python +def _validate_contract(spec, st, sid, errors, warnings): + 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', 'options'): + if st.get(k): + errors.append(f'{sid}: a contract step has no {k} — the rows are the picture') + _headline_and_words(st, sid, errors) + 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') + if word_count(st.get('risk')) > RISK_WARN: + warnings.append(f'{sid}: risk is {word_count(st["risk"])} words — keep it to one sentence') +``` + +- [ ] **Step 4: build.py** + +Add and dispatch **before** `is_words` in `deck_data`: + +```python +ROW_KEYS = ('id', 'statement', 'checkedBy', 'guard', 'threshold', 'source', 'note', 'verdict', 'evidence') + + +def _contract_step(spec, st): + """The rows, verbatim, and the two buttons a sign-off needs. Laid out as a words step.""" + return {'id': st['id'], 'kind': 'contract', 'words': True, 'surface': st['surface'], 'path': st['path'], + 'headline': st['headline'], 'notice': st.get('notice', ''), 'risk': st.get('risk', ''), + 'rows': [{k: r.get(k, '') for k in ROW_KEYS} for r in st['rows']], + 'yes': st.get('yes', 'Yes, that is done'), 'no': st.get('no', 'No, something is missing')} +``` + +In `build_page`'s loop, the `is_words` skip from Task 1 becomes `if is_words(st) or is_contract(st): continue` (import `is_contract`). + +- [ ] **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 in `page.css`; check the variable names at lines 58–60 and use whatever they are.) + +- [ ] **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` 8). + +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)`; `acceptance_spec(spec, verdicts) -> dict`; CLI `review-cards.py contract-check ` (exit 0/1, one problem per line on stderr) and `review-cards.py acceptance ` (writes `.acceptance.json` beside it from `.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 does not exist' in x for x in problems), problems) + + 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()); self.assertIn('contract holds: 3 rows', 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 contract's three facts, and the acceptance deck built from it. + +contract-check reads what the design calls the gate (feature-flow design §4): 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 is +on disk. It never blocks anything — close-out.sh prints its result in a Contract section. + +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 + +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 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 os.path.exists(os.path.join(root, r.get('guard', ''))): + problems.append(f'{tag}: guard {r.get("guard")} does not exist under {root}') + return problems + + +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, check_contract, contract_steps`; 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)) + print(f'contract holds: {n} rows, every source answered and submitted, every guard on disk') + 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; every mechanical guard exists (exit 1 lists what doesn't) + python3 scripts/ui-review/review-cards.py acceptance .contract.json + merge .verdicts.json into .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: 16 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); a contract spec's top-level `"branch"`. +- Produces: a `Contract` section with `OK` / `TODO` / `--` lines, always exit 0. + +- [ ] **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 whose +# acceptance deck was never submitted → TODO; a submitted one → OK. 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 +mkdir -p "$TMP/docs/active/design/x" && mv "$TMP/deck/"* "$TMP/docs/active/design/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" <<<"$out" || { echo "expected 'contract holds'"; echo "$out"; exit 1; } +grep -q "TODO.*acceptance deck not submitted" <<<"$out" || { echo "expected acceptance TODO"; echo "$out"; exit 1; } + +echo '{"submitted":"2026-09-01T12:00:00Z","answers":{"C":{"v":"yes"},"R2":{"v":"yes"}}}' > "$TMP/docs/active/design/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.*acceptance deck submitted" <<<"$out" || { echo "expected acceptance OK"; 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"/}" + if OUT=$(python3 "$WORKSPACE/scripts/ui-review/review-cards.py" contract-check "$c" 2>&1); then + pass "contract holds — $REL ($OUT)" + else + fail "contract does not hold — $REL:" + echo "$OUT" | sed 's/^/ /' + fi + ACC="${c%.contract.json}.contract.acceptance.answers.json" + if [[ -f "$ACC" ]] && python3 -c "import json,sys; sys.exit(0 if json.load(open('$ACC')).get('submitted') else 1)" 2>/dev/null; then + pass "acceptance deck submitted — $(basename "$ACC")" + else + fail "acceptance deck not submitted — review-cards.py acceptance $REL, then serve the .acceptance.json it writes" + fi + done <<<"$CONTRACTS" +fi +``` + +(`rotate_submitted` also applies to acceptance decks; a stamped `*.acceptance.answers.*.json` that is submitted should count — add the same newest-submitted glob in the python one-liner if the first end-to-end run trips on it.) + +- [ ] **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`) 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` +- Create: `docs/archive/design/2026-08-30-games-arcade/games-arcade.contract.json` (the dry run's output) + +**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 an EXISTING test or guard path (workspace-relative) that + checks the statement. 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 is not delivered. +``` + +- [ ] **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 `docs/archive/design/2026-08-30-games-arcade/games-arcade.contract.json`. (The arcade had no questions deck; say so in the dispatch.) + +Then run: `python3 scripts/ui-review/review-cards.py contract-check docs/archive/design/2026-08-30-games-arcade/games-arcade.contract.json` +Expected: `contract holds: N rows …`. 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. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/ui-review/contract-agent.md docs/archive/design/2026-08-30-games-arcade/games-arcade.contract.json +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. + +## Acceptance is graded rows plus human rows +**Invariant:** the grader writes `.verdicts.json`; `review-cards.py acceptance` refuses +when a `mechanical` or `deck` row has no verdict. `close-out.sh` reports both facts. +**Why:** an ungraded row is not a pass. +**Guard:** `test_contract.py` (AcceptanceTests); `close-out-contract.test.sh`. +``` + +- [ ] **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 `.verdicts.json`, run `review-cards.py acceptance`, serve the + acceptance deck; `bash scripts/close-out.sh ` reports both. + +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). + +Run: `cd worktrees/feature-flow && touch scripts/ui-review/deck/contract.py` in a fresh session, 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: Run it once, end to end + +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; 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)` 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 both `check_contract` and the tests expect. `note_kind` values `now|later|noting` match page.js, serve.py `NOTE_KIND`, the fixture and the agent prompt. 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..7204397d --- /dev/null +++ b/docs/active/specs/2026-09-01-feature-flow-design.md @@ -0,0 +1,142 @@ +--- +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: bc2e656 (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. 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.** 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. "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 (`.gitignore` lines 97–98, 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 file exists; every answers file its sources lean on has a non-null `submitted`; the contract deck itself was answered. `review-cards.py contract-check ` checks them and that every `source` resolves and every `mechanical` guard is on disk; `close-out.sh` runs it 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 `.verdicts.json` — `{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. Plan tier and the roadmap loop + +**Assumption (§9 Q3):** a plan document is written only when the work crosses repos, touches a migration or a protocol, or has ordering constraints. Otherwise the contract plus the approved decks *is* the plan, and the adversarial reviewers attack those. The 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. The `ui-mockup` skill's "capture decisions in a spec" step becomes "the contract is the record; write a design spec only under those three conditions". + +**"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 + +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** (§8) — plan documents only for cross-repo / migration / ordering work? +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. + +## 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. From 4aded5f5700eca478d5378ccde0aef0e1fffabdd Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 13:43:18 -0700 Subject: [PATCH 02/24] docs(plan): branch guidance for after PR #10 merges Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2 --- docs/active/plans/2026-09-01-feature-flow-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/active/plans/2026-09-01-feature-flow-plan.md b/docs/active/plans/2026-09-01-feature-flow-plan.md index c7f28028..7c7425d0 100644 --- a/docs/active/plans/2026-09-01-feature-flow-plan.md +++ b/docs/active/plans/2026-09-01-feature-flow-plan.md @@ -25,7 +25,7 @@ measured_at: - 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: `docs/feature-flow-plan` in worktree `worktrees/feature-flow` (already exists, holds the spec and this plan). Code tasks continue on the same branch. +- Branch: if youcoded-dev PR #10 (this plan) is still open, continue on `docs/feature-flow-plan` in `worktrees/feature-flow`; if it has merged, `git worktree add worktrees/feature-flow -b feat/feature-flow origin/master` and work there. --- From a2ddc25e52333d0cd10aeb0779290049de1f8a3f Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 19:33:42 -0700 Subject: [PATCH 03/24] =?UTF-8?q?docs(feature-flow):=20plan=20reviewed=20a?= =?UTF-8?q?gainst=20the=20code=20=E2=80=94=20third=20gate=20fact,=20guards?= =?UTF-8?q?=20on=20the=20branch,=20one=20verdicts=20name,=20contract=20as?= =?UTF-8?q?=20a=20words=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- .../plans/2026-09-01-feature-flow-plan.md | 477 +++++++++++++----- .../specs/2026-09-01-feature-flow-design.md | 10 +- 2 files changed, 356 insertions(+), 131 deletions(-) diff --git a/docs/active/plans/2026-09-01-feature-flow-plan.md b/docs/active/plans/2026-09-01-feature-flow-plan.md index 7c7425d0..3aa5f19d 100644 --- a/docs/active/plans/2026-09-01-feature-flow-plan.md +++ b/docs/active/plans/2026-09-01-feature-flow-plan.md @@ -4,7 +4,8 @@ created: 2026-09-01 type: plan spec: docs/active/specs/2026-09-01-feature-flow-design.md measured_at: - youcoded-dev: bc2e656 (origin/master) + 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 @@ -13,7 +14,7 @@ measured_at: **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/`). Two new step shapes — *words-only* (a step with no picture; `"words": true`) and *contract* (a step with `rows`) — 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. +**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. @@ -25,35 +26,29 @@ measured_at: - 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`; if it has merged, `git worktree add worktrees/feature-flow -b feat/feature-flow origin/master` and work there. +- 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:97-98` +- 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: Narrow the ignore to the scratch folder** +- [ ] **Step 1: Stop ignoring answers files** -Replace lines 97–98 of `.gitignore`: +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): ``` -*.answers.json -*.answers.*.json -``` - -with: - -``` -# Deck answers under docs/ are Destin's decisions and are COMMITTED (feature-flow design §2). -# Only throwaway decks in scratch/ stay untracked. `*.serve.json` below is a runtime lock. -scratch/**/*.answers.json -scratch/**/*.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. ``` - [ ] **Step 2: Verify the rule from the worktree** @@ -61,12 +56,12 @@ scratch/**/*.answers.*.json Run: ```bash cd /home/destin/youcoded-dev/worktrees/feature-flow -touch docs/active/design/probe.answers.json scratch-probe.answers.json +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 scratch/probe.answers.json +rm -f docs/active/design/probe.answers.json scratch/probe.answers.json ``` -Expected: `docs rc=1` (not ignored), `scratch rc=0` with the `.gitignore:` line printed. +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** @@ -91,7 +86,8 @@ git commit -m "chore(deck): commit answers files under docs/ — they are Destin 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/ stays ignored. +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" @@ -102,7 +98,7 @@ 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 59; `validate` dispatch ~line 174; `_validate_decide` lines 218–259; new `is_words`, `no_pictures`, `_validate_words`, `_validate_options`) +- 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) @@ -248,6 +244,9 @@ class WordsTests(unittest.TestCase): # 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__': @@ -261,31 +260,34 @@ 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 110) add: +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); without, a statement to approve (`changed` + `notice` are its body). Two users: - the QUESTIONS deck answered before anything is drawn, and the acceptance deck's - human rows (feature-flow design §5, §7).""" - return step.get('words') is True - - -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. Always words-only. Validation and the page - come in Task 3 of the plan; the predicate lives here so no_pictures() is written once.""" - return bool(step.get('rows')) + 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, words-only or a contract. - 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) or is_contract(st) for st in spec['steps']) + """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.) @@ -300,7 +302,7 @@ In `validate`, after the `is_live` block and before `if is_choice(st):`, add: continue ``` -Extract the option loop out of `_validate_decide`. Replace lines 233–256 (from `opts = st['options']` through the `measured has no number` warning) with: +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) @@ -339,7 +341,8 @@ def _validate_options(st, sid, errors, warnings, minimum): 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.""" + 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}') @@ -347,7 +350,9 @@ def _validate_words(spec, st, sid, errors, warnings): 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 st.get('options'): + 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'): @@ -368,14 +373,21 @@ Note the `_validate_decide` docstring's claim "at least 2 options" now lives in - [ ] **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 42) with `if no_pictures(spec):`, drop `all_live` from the `.live` import if nothing else uses it there, and after the `if is_live(st): continue` in the loop add: +`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 - if is_words(st) or is_contract(st): - continue # words only — nothing to cut, no `crop` to look up +def _validate_rows(spec, st, sid, errors): + errors.append(f'{sid}: contract steps are not supported yet (plan Task 3)') ``` -(import `is_contract` from `.spec` alongside `is_words`.) +(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** @@ -415,9 +427,11 @@ In `build_page`'s loop, after `if is_live(st): continue`, add: ```python if is_words(st): - continue # nothing on disk to check + 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`: @@ -638,8 +652,8 @@ Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" ### Task 3: The contract step **Files:** -- Modify: `scripts/ui-review/deck/spec.py` (`is_contract`, `_validate_contract`, dispatch, constants) -- Modify: `scripts/ui-review/deck/build.py` (`_contract_step`, dispatch) +- 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`) @@ -647,7 +661,7 @@ Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" - Create: `scripts/ui-review/templates/contract.json` (the template Task 6's agent copies) **Interfaces:** -- Produces: `is_contract(step) -> bool` (`bool(step.get('rows'))`); `CHECKED_BY = ('mechanical', 'deck', 'live-app', 'human')`; deck data `{'id', 'kind': 'contract', 'words': True, 'surface', 'path', 'headline', 'notice', 'risk', 'rows': [{'id','statement','checkedBy','guard','threshold','source','note','verdict','evidence'}], 'yes', 'no'}`; `fixture.contract_spec(tmp, **over) -> path` which also writes two source decks with **submitted** answers files beside it. +- 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** @@ -778,12 +792,23 @@ class ContractStepTests(unittest.TestCase): 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) @@ -797,32 +822,21 @@ Expected: `ImportError: cannot import name 'is_contract'`. - [ ] **Step 3: spec.py** -`is_contract` already exists (Task 1). Add near the other constants: +`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.-]+$') ``` -In `validate`, before the `is_words` dispatch from Task 1: - -```python - if is_contract(st): - _validate_contract(spec, st, sid, errors, warnings) - continue -``` - -Add: +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_contract(spec, st, sid, errors, warnings): - 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', 'options'): - if st.get(k): - errors.append(f'{sid}: a contract step has no {k} — the rows are the picture') - _headline_and_words(st, sid, errors) +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') @@ -857,27 +871,34 @@ def _validate_contract(spec, st, sid, errors, warnings): 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') - if word_count(st.get('risk')) > RISK_WARN: - warnings.append(f'{sid}: risk is {word_count(st["risk"])} words — keep it to one sentence') ``` +(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 and dispatch **before** `is_words` in `deck_data`: +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: -def _contract_step(spec, st): - """The rows, verbatim, and the two buttons a sign-off needs. Laid out as a words step.""" - return {'id': st['id'], 'kind': 'contract', 'words': True, 'surface': st['surface'], 'path': st['path'], - 'headline': st['headline'], 'notice': st.get('notice', ''), 'risk': st.get('risk', ''), - 'rows': [{k: r.get(k, '') for k in ROW_KEYS} for r in st['rows']], - 'yes': st.get('yes', 'Yes, that is done'), 'no': st.get('no', 'No, something is missing')} +```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 ``` -In `build_page`'s loop, the `is_words` skip from Task 1 becomes `if is_words(st) or is_contract(st): continue` (import `is_contract`). +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** @@ -903,7 +924,7 @@ In `render()`'s `$('#cards').innerHTML = …` chain, add a first branch: .words .cards:has(.contract){grid-template-columns:1fr} ``` -(`--yes` / `--no` are the existing answer-button colours in `page.css`; check the variable names at lines 58–60 and use whatever they are.) +(`--yes` / `--no` are the existing answer-button colours, defined on `:root` at the top of `page.css` — verified 2026-09-01.) - [ ] **Step 6: Template** @@ -912,7 +933,7 @@ Create `scripts/ui-review/templates/contract.json` — the fixture's contract sp - [ ] **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` 8). +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.` @@ -934,7 +955,9 @@ Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" - 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)`; `acceptance_spec(spec, verdicts) -> dict`; CLI `review-cards.py contract-check ` (exit 0/1, one problem per line on stderr) and `review-cards.py acceptance ` (writes `.acceptance.json` beside it from `.verdicts.json`, prints the path; exit 1 with reasons if a graded row lacks a verdict). +- 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** @@ -981,6 +1004,58 @@ class ContractCheckTests(unittest.TestCase): 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 does not exist' 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')) @@ -991,7 +1066,18 @@ class ContractCheckTests(unittest.TestCase): 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()); self.assertIn('contract holds: 3 rows', out.getvalue()) + 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): @@ -1031,18 +1117,22 @@ Expected: the new cases fail with `ModuleNotFoundError: No module named 'deck.co Create `scripts/ui-review/deck/contract.py`: ```python -"""The contract's three facts, and the acceptance deck built from it. +"""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): 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 is -on disk. It never blocks anything — close-out.sh prints its result in a Contract section. +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 @@ -1055,6 +1145,33 @@ 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. @@ -1111,11 +1228,38 @@ def check_contract(spec): 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 os.path.exists(os.path.join(root, r.get('guard', ''))): - problems.append(f'{tag}: guard {r.get("guard")} does not exist under {root}') + 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') @@ -1149,7 +1293,7 @@ def acceptance_spec(spec, verdicts): - [ ] **Step 3: CLI** -In `review-cards.py`: import `from deck.contract import AcceptanceError, acceptance_spec, check_contract, contract_steps`; register `for c in ('build', 'serve', 'wait', 'contract-check', 'acceptance'):`; in `main` before the `serve` branch: +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': @@ -1161,7 +1305,11 @@ In `review-cards.py`: import `from deck.contract import AcceptanceError, accepta print('\n'.join(problems), file=sys.stderr) return 1 n = sum(len(st['rows']) for st in contract_steps(spec)) - print(f'contract holds: {n} rows, every source answered and submitted, every guard on disk') + # 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') @@ -1187,15 +1335,17 @@ 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; every mechanical guard exists (exit 1 lists what doesn't) + 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 .verdicts.json into .acceptance.json — the contract graded, plus a yes/no per human row + 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: 16 tests pass. +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 @@ -1214,8 +1364,8 @@ Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" - Create: `scripts/ui-review/tests/close-out-contract.test.sh` **Interfaces:** -- Consumes: `review-cards.py contract-check` (Task 4); a contract spec's top-level `"branch"`. -- Produces: a `Contract` section with `OK` / `TODO` / `--` lines, always exit 0. +- 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** @@ -1223,13 +1373,14 @@ 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 whose -# acceptance deck was never submitted → TODO; a submitted one → OK. Runs against a temp docs dir. +# 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 -mkdir -p "$TMP/docs/active/design/x" && mv "$TMP/deck/"* "$TMP/docs/active/design/x/" +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; } @@ -1237,12 +1388,23 @@ grep -q "no contract names this branch" <<<"$out" || { echo "missing 'no contrac # 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" <<<"$out" || { echo "expected 'contract holds'"; echo "$out"; exit 1; } -grep -q "TODO.*acceptance deck not submitted" <<<"$out" || { echo "expected acceptance TODO"; echo "$out"; exit 1; } +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-01T12:00:00Z","answers":{"C":{"v":"yes"},"R2":{"v":"yes"}}}' > "$TMP/docs/active/design/x/arcade.contract.acceptance.answers.json" +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" ``` @@ -1272,30 +1434,33 @@ if [[ -z "$CONTRACTS" ]]; then 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 - pass "contract holds — $REL ($OUT)" + 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 - ACC="${c%.contract.json}.contract.acceptance.answers.json" - if [[ -f "$ACC" ]] && python3 -c "import json,sys; sys.exit(0 if json.load(open('$ACC')).get('submitted') else 1)" 2>/dev/null; then - pass "acceptance deck submitted — $(basename "$ACC")" - else - fail "acceptance deck not submitted — review-cards.py acceptance $REL, then serve the .acceptance.json it writes" - fi done <<<"$CONTRACTS" fi ``` -(`rotate_submitted` also applies to acceptance decks; a stamped `*.acceptance.answers.*.json` that is submitted should count — add the same newest-submitted glob in the python one-liner if the first end-to-end run trips on it.) +(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`) and to the close-out header comment (`# Contract section: feature-flow design §4`). +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 @@ -1311,7 +1476,7 @@ Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" **Files:** - Create: `scripts/ui-review/contract-agent.md` -- Create: `docs/archive/design/2026-08-30-games-arcade/games-arcade.contract.json` (the dry run's output) +- 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`. @@ -1352,9 +1517,10 @@ answers do not support a row, the row does not exist; write what was missed into - `no` / `skip` → no row. A skipped step is unanswered, never "fine". ## `checkedBy` -- `mechanical` only when you can name an EXISTING test or guard path (workspace-relative) that - checks the statement. Do not invent one; if none exists, the row is `human` and you say so - in `## Not covered` ("R4 needs a test"). +- `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. @@ -1365,22 +1531,25 @@ answers do not support a row, the row does not exist; write what was missed into - 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 is not delivered. + 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 `docs/archive/design/2026-08-30-games-arcade/games-arcade.contract.json`. (The arcade had no questions deck; say so in the dispatch.) +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 docs/archive/design/2026-08-30-games-arcade/games-arcade.contract.json` -Expected: `contract holds: N rows …`. 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. +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 docs/archive/design/2026-08-30-games-arcade/games-arcade.contract.json +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" ``` @@ -1448,11 +1617,22 @@ 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 `.verdicts.json`; `review-cards.py acceptance` refuses -when a `mechanical` or `deck` row has no verdict. `close-out.sh` reports both facts. +**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); `close-out-contract.test.sh`. +**Guard:** `test_contract.py` (AcceptanceTests). ``` - [ ] **Step 2: The skill** @@ -1490,8 +1670,9 @@ Decisions must not live only in chat — and the deck answers ARE the record (th 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 `.verdicts.json`, run `review-cards.py acceptance`, serve the - acceptance deck; `bash scripts/close-out.sh ` reports both. +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. ``` @@ -1508,7 +1689,7 @@ Merging cannot shift appearance, because nothing was ever copied. Run: `node scripts/audit-anchors.mjs` Expected: green (the new rule's `verify:` entries resolve). -Run: `cd worktrees/feature-flow && touch scripts/ui-review/deck/contract.py` in a fresh session, 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. +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** @@ -1522,7 +1703,35 @@ Claude-Session: https://claude.ai/code/session_01CiVWE2jGoEVCkp9bYYtuE2" --- -### Task 8: Run it once, end to end +### 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: @@ -1544,4 +1753,18 @@ Then `/wrap-up`. The design doc's `status:` flips to `active` on the first run a **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)` 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 both `check_contract` and the tests expect. `note_kind` values `now|later|noting` match page.js, serve.py `NOTE_KIND`, the fixture and the agent prompt. +**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/specs/2026-09-01-feature-flow-design.md b/docs/active/specs/2026-09-01-feature-flow-design.md index 7204397d..89844c9a 100644 --- a/docs/active/specs/2026-09-01-feature-flow-design.md +++ b/docs/active/specs/2026-09-01-feature-flow-design.md @@ -5,7 +5,7 @@ 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: bc2e656 (origin/master) + youcoded-dev: 5dacdf7 (origin/master) youcoded: ddac2f14 --- @@ -54,7 +54,7 @@ Three states, because "the tool exists" and "the step happens" are different fac **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 (`.gitignore` lines 97–98, 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). +**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. @@ -91,7 +91,7 @@ Enforcement today is CLAUDE.md plus unticked boxes: zero hits for `checkpoint` i - 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 file exists; every answers file its sources lean on has a non-null `submitted`; the contract deck itself was answered. `review-cards.py contract-check ` checks them and that every `source` resolves and every `mechanical` guard is on disk; `close-out.sh` runs it 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. +**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 @@ -112,7 +112,7 @@ When implementation disproves approved UI (arcade contrast, marketplace's dead U ## 7. Acceptance -The grader writes `.verdicts.json` — `{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. +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. Plan tier and the roadmap loop @@ -122,6 +122,8 @@ The grader writes `.verdicts.json` — `{rowId: {verdict: pass|fail, ev ## 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). + 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** (§8) — plan documents only for cross-repo / migration / ordering work? From 9ba88eb99c4dd957832880d1f2a94daea5a75ac0 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 19:33:57 -0700 Subject: [PATCH 04/24] =?UTF-8?q?chore(deck):=20commit=20answers=20files?= =?UTF-8?q?=20under=20docs/=20=E2=80=94=20they=20are=20Destin's=20decision?= =?UTF-8?q?s,=20not=20runtime=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_01F35AsThZGxRFAARcyurigf --- .gitignore | 4 +- .../phase-d-brief.answers.json | 61 +++++++++ .../phase-d-mockups.answers.json | 66 ++++++++++ .../phase-d-review.answers.json | 39 ++++++ .../phase-d-review2.answers.json | 14 ++ .../phase-d-review3.answers.json | 20 +++ .../phase-e-brief.answers.json | 33 +++++ .../phase-e-review.answers.json | 21 +++ .../phase-e-review2.answers.json | 22 ++++ .../phase-e-review3.answers.json | 14 ++ .../chatsearch-gate.answers.json | 122 ++++++++++++++++++ .../marketplace-deck-r2.answers.json | 52 ++++++++ .../marketplace-deck-r3.answers.json | 26 ++++ .../marketplace-deck.answers.json | 75 +++++++++++ .../ask-question-other.answers.json | 26 ++++ .../bash-background.answers.json | 38 ++++++ .../2026-08-28-chip-edit/review.answers.json | 14 ++ .../session-motion.answers.json | 32 +++++ .../artifact-zoom-deck.answers.json | 28 ++++ .../artifact-zoom-pdf-deck.answers.json | 26 ++++ .../artifact-zoom-r2.answers.json | 22 ++++ .../copy.preview.answers.json | 39 ++++++ .../loop-review-2/copy.preview.answers.json | 51 ++++++++ .../loop-review-3/copy.preview.answers.json | 39 ++++++ .../loop-review/copy.preview.answers.json | 71 ++++++++++ .../board-contrast.answers.json | 15 +++ .../head-to-head.answers.json | 21 +++ .../step1-sizing.answers.json | 21 +++ 28 files changed, 1010 insertions(+), 2 deletions(-) create mode 100644 docs/active/design/2026-08-25-ui-audit/phase-d-brief.answers.json create mode 100644 docs/active/design/2026-08-25-ui-audit/phase-d-mockups.answers.json create mode 100644 docs/active/design/2026-08-25-ui-audit/phase-d-review.answers.json create mode 100644 docs/active/design/2026-08-25-ui-audit/phase-d-review2.answers.json create mode 100644 docs/active/design/2026-08-25-ui-audit/phase-d-review3.answers.json create mode 100644 docs/active/design/2026-08-25-ui-audit/phase-e-brief.answers.json create mode 100644 docs/active/design/2026-08-25-ui-audit/phase-e-review.answers.json create mode 100644 docs/active/design/2026-08-25-ui-audit/phase-e-review2.answers.json create mode 100644 docs/active/design/2026-08-25-ui-audit/phase-e-review3.answers.json create mode 100644 docs/active/design/2026-08-27-chatsearch-refs-gate/chatsearch-gate.answers.json create mode 100644 docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck-r2.answers.json create mode 100644 docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck-r3.answers.json create mode 100644 docs/active/design/2026-08-27-marketplace-overhaul/marketplace-deck.answers.json create mode 100644 docs/active/design/2026-08-28-ask-question-other/ask-question-other.answers.json create mode 100644 docs/active/design/2026-08-28-bash-background/bash-background.answers.json create mode 100644 docs/active/design/2026-08-28-chip-edit/review.answers.json create mode 100644 docs/active/design/2026-08-31-session-motion/session-motion.answers.json create mode 100644 docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-deck.answers.json create mode 100644 docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-pdf-deck.answers.json create mode 100644 docs/archive/design/2026-08-27-artifact-zoom/artifact-zoom-r2.answers.json create mode 100644 docs/archive/design/2026-08-27-landing-page/copy.preview.answers.json create mode 100644 docs/archive/design/2026-08-27-landing-page/loop-review-2/copy.preview.answers.json create mode 100644 docs/archive/design/2026-08-27-landing-page/loop-review-3/copy.preview.answers.json create mode 100644 docs/archive/design/2026-08-27-landing-page/loop-review/copy.preview.answers.json create mode 100644 docs/archive/design/2026-08-30-games-arcade/board-contrast.answers.json create mode 100644 docs/archive/design/2026-08-30-games-arcade/head-to-head.answers.json create mode 100644 docs/archive/design/2026-08-30-games-arcade/step1-sizing.answers.json 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/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/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 From 383bfea969ffe31f0d981fdb882949bcb7061937 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 19:42:23 -0700 Subject: [PATCH 05/24] =?UTF-8?q?feat(deck):=20words-only=20steps=20?= =?UTF-8?q?=E2=80=94=20a=20question=20deck=20with=20no=20picture,=20one=20?= =?UTF-8?q?option=20is=20enough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- .github/workflows/workspace-ci.yml | 8 +- docs/MAP.md | 2 +- scripts/ui-review/README.md | 8 +- scripts/ui-review/deck/build.py | 28 +++++- scripts/ui-review/deck/crops.py | 12 ++- scripts/ui-review/deck/page.css | 3 + scripts/ui-review/deck/page.js | 19 +++- scripts/ui-review/deck/spec.py | 86 ++++++++++++++++-- scripts/ui-review/review-cards.py | 4 + scripts/ui-review/tests/deck-render.test.mjs | 30 ++++++ scripts/ui-review/tests/fixture.py | 33 +++++++ scripts/ui-review/tests/test_words.py | 96 ++++++++++++++++++++ 12 files changed, 301 insertions(+), 28 deletions(-) create mode 100644 scripts/ui-review/tests/test_words.py diff --git a/.github/workflows/workspace-ci.yml b/.github/workflows/workspace-ci.yml index e9df0a48..eefeaa8d 100644 --- a/.github/workflows/workspace-ci.yml +++ b/.github/workflows/workspace-ci.yml @@ -93,18 +93,18 @@ 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 four 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 and test_words 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 # 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/docs/MAP.md b/docs/MAP.md index da0852e0..0d6c2a5b 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`)
`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 test_words` (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/scripts/ui-review/README.md b/scripts/ui-review/README.md index e4636134..8266024d 100644 --- a/scripts/ui-review/README.md +++ b/scripts/ui-review/README.md @@ -255,14 +255,14 @@ 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 four 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 ``` -Everything (98 tests, ~12s) — needs `magick`, `ffmpeg` and Chrome, all present on this machine: +Everything (107 tests, ~38s) — needs `magick`, `ffmpeg` and Chrome, all present on this machine: ```bash @@ -278,7 +278,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` | nothing — **these four 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/deck/build.py b/scripts/ui-review/deck/build.py index fb61ac33..4171cbdd 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_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,38 @@ 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', '')} + + 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 `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 + + 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 +144,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 +176,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/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/page.css b/scripts/ui-review/deck/page.css index 20195ed3..08e0c2b3 100644 --- a/scripts/ui-review/deck/page.css +++ b/scripts/ui-review/deck/page.css @@ -70,6 +70,9 @@ 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} .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 diff --git a/scripts/ui-review/deck/page.js b/scripts/ui-review/deck/page.js index 62a7fe3d..cf2d2c31 100644 --- a/scripts/ui-review/deck/page.js +++ b/scripts/ui-review/deck/page.js @@ -102,6 +102,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 +124,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 +156,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 @@ -194,9 +198,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('') @@ -224,6 +229,10 @@ // ── 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'; 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; diff --git a/scripts/ui-review/deck/spec.py b/scripts/ui-review/deck/spec.py index 578fc1d7..10357a7f 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) @@ -56,10 +56,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 +118,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 +203,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 +261,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 +295,30 @@ 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 (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') @@ -258,6 +326,12 @@ 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): + # Placeholder until Task 3: reaching the contract validator (not "missing crop") is + # already correct behaviour for is_words/is_contract — the real row checks land there. + errors.append(f'{sid}: contract steps are not supported yet (plan Task 3)') + + 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..f64df1fd 100644 --- a/scripts/ui-review/review-cards.py +++ b/scripts/ui-review/review-cards.py @@ -17,6 +17,10 @@ absence a yes/no, and `serve` boots the worktree's workbench for it). Wording-only questions are not a step: ask in chat. +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. + 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 wrong rings with no error in v1. Spec format + writing rules: diff --git a/scripts/ui-review/tests/deck-render.test.mjs b/scripts/ui-review/tests/deck-render.test.mjs index 06e71cc0..5b46c5b3 100644 --- a/scripts/ui-review/tests/deck-render.test.mjs +++ b/scripts/ui-review/tests/deck-render.test.mjs @@ -241,3 +241,33 @@ 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 + 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(); } +}); diff --git a/scripts/ui-review/tests/fixture.py b/scripts/ui-review/tests/fixture.py index 932de3f9..75321c26 100644 --- a/scripts/ui-review/tests/fixture.py +++ b/scripts/ui-review/tests/fixture.py @@ -145,3 +145,36 @@ 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 diff --git a/scripts/ui-review/tests/test_words.py b/scripts/ui-review/tests/test_words.py new file mode 100644 index 00000000..e907083a --- /dev/null +++ b/scripts/ui-review/tests/test_words.py @@ -0,0 +1,96 @@ +"""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() From 2fcc1033869b99708a1c4c8c41766b78d718b56c Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 19:48:12 -0700 Subject: [PATCH 06/24] fix(deck): banned words apply to a words step's button labels; drop the "ask wording in chat" sentence the words step replaces Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- scripts/ui-review/deck/spec.py | 4 ++++ scripts/ui-review/review-cards.py | 3 +-- scripts/ui-review/tests/test_words.py | 6 ++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/ui-review/deck/spec.py b/scripts/ui-review/deck/spec.py index 10357a7f..2bb2e724 100644 --- a/scripts/ui-review/deck/spec.py +++ b/scripts/ui-review/deck/spec.py @@ -319,6 +319,10 @@ def _validate_words(spec, st, sid, errors, warnings): 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') diff --git a/scripts/ui-review/review-cards.py b/scripts/ui-review/review-cards.py index f64df1fd..6d5c9d7d 100644 --- a/scripts/ui-review/review-cards.py +++ b/scripts/ui-review/review-cards.py @@ -14,8 +14,7 @@ `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 diff --git a/scripts/ui-review/tests/test_words.py b/scripts/ui-review/tests/test_words.py index e907083a..4cba5626 100644 --- a/scripts/ui-review/tests/test_words.py +++ b/scripts/ui-review/tests/test_words.py @@ -65,6 +65,12 @@ 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, {}) From 6d473623133e5b426daae52c23b4b48420ebb647 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 19:51:57 -0700 Subject: [PATCH 07/24] =?UTF-8?q?feat(deck):=20a=20note=20carries=20a=20ta?= =?UTF-8?q?g=20=E2=80=94=20fix=20now=20/=20fix=20later=20/=20just=20noting?= =?UTF-8?q?=20=E2=80=94=20so=20nothing=20about=20it=20is=20inferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- scripts/ui-review/deck/page.css | 3 +++ scripts/ui-review/deck/page.html.tmpl | 2 ++ scripts/ui-review/deck/page.js | 22 +++++++++++++++++--- scripts/ui-review/deck/serve.py | 9 +++++++- scripts/ui-review/tests/deck-render.test.mjs | 8 +++++++ scripts/ui-review/tests/test_words.py | 10 +++++++++ 6 files changed, 50 insertions(+), 4 deletions(-) diff --git a/scripts/ui-review/deck/page.css b/scripts/ui-review/deck/page.css index 08e0c2b3..03367311 100644 --- a/scripts/ui-review/deck/page.css +++ b/scripts/ui-review/deck/page.css @@ -166,6 +166,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 cf2d2c31..d43c4602 100644 --- a/scripts/ui-review/deck/page.js +++ b/scripts/ui-review/deck/page.js @@ -220,6 +220,10 @@ $$('.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. + 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'))); $$('#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) @@ -278,7 +282,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)); @@ -286,7 +301,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); lines.push(st.id + ' ' + what + (a.note && a.note.trim() ? ' — "' + a.note.trim() + '"' + (a.note_kind ? ' [' + {now:'fix now',later:'fix later',noting:'just noting'}[a.note_kind] + ']' : '') : '')); } 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() { @@ -322,7 +338,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/tests/deck-render.test.mjs b/scripts/ui-review/tests/deck-render.test.mjs index 5b46c5b3..0bd6c251 100644 --- a/scripts/ui-review/tests/deck-render.test.mjs +++ b/scripts/ui-review/tests/deck-render.test.mjs @@ -261,12 +261,20 @@ test('a words-only deck renders with no stage and records a pick', async () => { 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'); assert.deepEqual(c.errors, []); } finally { c.close(); } } finally { srv.kill(); } diff --git a/scripts/ui-review/tests/test_words.py b/scripts/ui-review/tests/test_words.py index 4cba5626..dbb65c45 100644 --- a/scripts/ui-review/tests/test_words.py +++ b/scripts/ui-review/tests/test_words.py @@ -89,6 +89,16 @@ def test_crop_and_build_skip_words_steps(self): 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'})) From 7b03d6dffa44caef4ada858eafb42533f9d24071 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 19:57:53 -0700 Subject: [PATCH 08/24] =?UTF-8?q?fix(deck):=20a=20note=20tag=20is=20shown?= =?UTF-8?q?=20only=20when=20stored=20=E2=80=94=20an=20old=20untagged=20not?= =?UTF-8?q?e=20offers=20the=20buttons,=20selects=20none?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- scripts/ui-review/deck/page.js | 10 ++++++++-- scripts/ui-review/tests/deck-render.test.mjs | 9 +++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/ui-review/deck/page.js b/scripts/ui-review/deck/page.js index d43c4602..67cd9a14 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 @@ -221,9 +224,12 @@ $$('#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 || 'noting'))); + $$('#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) @@ -302,7 +308,7 @@ function summary() { const counts = { yes: 0, no: 0, other: 0, skip: 0 }; const lines = []; // 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); lines.push(st.id + ' ' + what + (a.note && a.note.trim() ? ' — "' + a.note.trim() + '"' + (a.note_kind ? ' [' + {now:'fix now',later:'fix later',noting:'just noting'}[a.note_kind] + ']' : '') : '')); } + 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() { diff --git a/scripts/ui-review/tests/deck-render.test.mjs b/scripts/ui-review/tests/deck-render.test.mjs index 0bd6c251..ad8c469b 100644 --- a/scripts/ui-review/tests/deck-render.test.mjs +++ b/scripts/ui-review/tests/deck-render.test.mjs @@ -275,6 +275,15 @@ test('a words-only deck renders with no stage and records a pick', async () => { 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(); } From 425a5d9050c8b8eb976b2ddeb27ad2cd07b409ca Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:03:39 -0700 Subject: [PATCH 09/24] =?UTF-8?q?feat(deck):=20the=20contract=20step=20?= =?UTF-8?q?=E2=80=94=20the=20rows=20that=20define=20done,=20signed=20off?= =?UTF-8?q?=20as=20one=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- .github/workflows/workspace-ci.yml | 9 ++- docs/MAP.md | 2 +- scripts/ui-review/README.md | 8 +- scripts/ui-review/deck/build.py | 19 ++++- scripts/ui-review/deck/page.css | 4 + scripts/ui-review/deck/page.js | 10 ++- scripts/ui-review/deck/spec.py | 50 +++++++++++-- scripts/ui-review/review-cards.py | 3 + scripts/ui-review/templates/contract.json | 36 +++++++++ scripts/ui-review/tests/fixture.py | 51 +++++++++++++ scripts/ui-review/tests/test_contract.py | 90 +++++++++++++++++++++++ 11 files changed, 263 insertions(+), 19 deletions(-) create mode 100644 scripts/ui-review/templates/contract.json create mode 100644 scripts/ui-review/tests/test_contract.py diff --git a/.github/workflows/workspace-ci.yml b/.github/workflows/workspace-ci.yml index eefeaa8d..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 four 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 and test_words are 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 test_words + 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/docs/MAP.md b/docs/MAP.md index 0d6c2a5b..bd79e838 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 test_words` (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`)
`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 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/scripts/ui-review/README.md b/scripts/ui-review/README.md index 8266024d..1ec3c68f 100644 --- a/scripts/ui-review/README.md +++ b/scripts/ui-review/README.md @@ -255,14 +255,14 @@ 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 four 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 test_words +cd scripts/ui-review/tests && python3 -m unittest test_spec test_tokens test_live test_words test_contract ``` -Everything (107 tests, ~38s) — needs `magick`, `ffmpeg` and Chrome, all present on this machine: +Everything (119 tests, ~38s) — needs `magick`, `ffmpeg` and Chrome, all present on this machine: ```bash @@ -278,7 +278,7 @@ months. | Suite | Needs | |---|---| -| `test_spec`, `test_tokens`, `test_live`, `test_words` | nothing — **these four 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/deck/build.py b/scripts/ui-review/deck/build.py index 4171cbdd..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, is_words, 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'} @@ -73,6 +73,10 @@ def _option(o): '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 { @@ -87,14 +91,21 @@ def _decide_step(spec, st, boxes, runs): 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".""" + 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 st.get('options'): + 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 diff --git a/scripts/ui-review/deck/page.css b/scripts/ui-review/deck/page.css index 03367311..966ac766 100644 --- a/scripts/ui-review/deck/page.css +++ b/scripts/ui-review/deck/page.css @@ -73,6 +73,10 @@ body.thumbs-inline .thumbs{position:static;flex-direction:row;justify-content:fl /* 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 diff --git a/scripts/ui-review/deck/page.js b/scripts/ui-review/deck/page.js index 67cd9a14..a1bff240 100644 --- a/scripts/ui-review/deck/page.js +++ b/scripts/ui-review/deck/page.js @@ -184,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)}

` : '') diff --git a/scripts/ui-review/deck/spec.py b/scripts/ui-review/deck/spec.py index 2bb2e724..d7af0272 100644 --- a/scripts/ui-review/deck/spec.py +++ b/scripts/ui-review/deck/spec.py @@ -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. @@ -300,7 +305,7 @@ def _validate_options(st, sid, errors, warnings, minimum): 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.""" + 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}') @@ -309,7 +314,7 @@ def _validate_words(spec, st, sid, errors, warnings): 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 + _validate_rows(spec, st, sid, errors) elif st.get('options'): _validate_options(st, sid, errors, warnings, minimum=1) else: @@ -331,9 +336,44 @@ def _validate_words(spec, st, sid, errors, warnings): def _validate_rows(spec, st, sid, errors): - # Placeholder until Task 3: reaching the contract validator (not "missing crop") is - # already correct behaviour for is_words/is_contract — the real row checks land there. - errors.append(f'{sid}: contract steps are not supported yet (plan Task 3)') + """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') def _validate_choice(spec, st, sid, errors, warnings): diff --git a/scripts/ui-review/review-cards.py b/scripts/ui-review/review-cards.py index 6d5c9d7d..e8805e57 100644 --- a/scripts/ui-review/review-cards.py +++ b/scripts/ui-review/review-cards.py @@ -20,6 +20,9 @@ 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 wrong rings with no error in v1. Spec format + writing rules: diff --git a/scripts/ui-review/templates/contract.json b/scripts/ui-review/templates/contract.json new file mode 100644 index 00000000..e1a7eb33 --- /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/fixture.py b/scripts/ui-review/tests/fixture.py index 75321c26..27ba54b0 100644 --- a/scripts/ui-review/tests/fixture.py +++ b/scripts/ui-review/tests/fixture.py @@ -178,3 +178,54 @@ def words_spec(tmp, **over): 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 = { + '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 diff --git a/scripts/ui-review/tests/test_contract.py b/scripts/ui-review/tests/test_contract.py new file mode 100644 index 00000000..da619a10 --- /dev/null +++ b/scripts/ui-review/tests/test_contract.py @@ -0,0 +1,90 @@ +"""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() From 211fae774a116dd3376e0c284ed2fb85e0440d01 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:11:38 -0700 Subject: [PATCH 10/24] 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_01F35AsThZGxRFAARcyurigf --- scripts/ui-review/deck/contract.py | 172 +++++++++++++++++++++++ scripts/ui-review/review-cards.py | 43 +++++- scripts/ui-review/tests/test_contract.py | 148 +++++++++++++++++++ 3 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 scripts/ui-review/deck/contract.py diff --git a/scripts/ui-review/deck/contract.py b/scripts/ui-review/deck/contract.py new file mode 100644 index 00000000..0cd447a6 --- /dev/null +++ b/scripts/ui-review/deck/contract.py @@ -0,0 +1,172 @@ +"""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} diff --git a/scripts/ui-review/review-cards.py b/scripts/ui-review/review-cards.py index e8805e57..7be29dac 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 @@ -29,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 @@ -68,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)') @@ -85,6 +93,39 @@ def main(argv): return build(spec) if a.cmd == 'wait': return wait_for_submit(spec, timeout_min=a.timeout) + 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 # 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/tests/test_contract.py b/scripts/ui-review/tests/test_contract.py index da619a10..d02b16d6 100644 --- a/scripts/ui-review/tests/test_contract.py +++ b/scripts/ui-review/tests/test_contract.py @@ -86,5 +86,153 @@ def test_deck_data_and_page(self): 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 = 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) + # 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') + 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], []) + + if __name__ == '__main__': unittest.main() From cf638e3f46f6a60745cc6ad05b190e6a756a861b Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:12:44 -0700 Subject: [PATCH 11/24] docs(plan): Task 4 guard assertion text matches the message contract Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- docs/active/plans/2026-09-01-feature-flow-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/active/plans/2026-09-01-feature-flow-plan.md b/docs/active/plans/2026-09-01-feature-flow-plan.md index 3aa5f19d..31a82d7b 100644 --- a/docs/active/plans/2026-09-01-feature-flow-plan.md +++ b/docs/active/plans/2026-09-01-feature-flow-plan.md @@ -1002,7 +1002,7 @@ class ContractCheckTests(unittest.TestCase): 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 does not exist' 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 From 46cd1a6d6b205ebfeebfb5012315e44d7e34a871 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:18:18 -0700 Subject: [PATCH 12/24] fix(deck): contract tests close their files; a missing git or a corrupt file reads as its real cause, not a traceback Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- scripts/ui-review/deck/contract.py | 13 +++++-- scripts/ui-review/review-cards.py | 6 ++-- scripts/ui-review/tests/test_contract.py | 46 ++++++++++++++++++------ 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/scripts/ui-review/deck/contract.py b/scripts/ui-review/deck/contract.py index 0cd447a6..f55034dc 100644 --- a/scripts/ui-review/deck/contract.py +++ b/scripts/ui-review/deck/contract.py @@ -47,7 +47,12 @@ def guard_exists(root, branch, guard): 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) + 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 @@ -135,8 +140,12 @@ def acceptance_status(spec): 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) + 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"))}' diff --git a/scripts/ui-review/review-cards.py b/scripts/ui-review/review-cards.py index 7be29dac..fa8523a6 100644 --- a/scripts/ui-review/review-cards.py +++ b/scripts/ui-review/review-cards.py @@ -113,8 +113,10 @@ def main(argv): 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) + # 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) diff --git a/scripts/ui-review/tests/test_contract.py b/scripts/ui-review/tests/test_contract.py index d02b16d6..d1f5b920 100644 --- a/scripts/ui-review/tests/test_contract.py +++ b/scripts/ui-review/tests/test_contract.py @@ -14,6 +14,19 @@ 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: @@ -99,7 +112,7 @@ 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')) + 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) @@ -109,7 +122,7 @@ def test_rotated_answers_are_found(self): 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')) + 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): @@ -139,9 +152,13 @@ def test_guard_committed_on_the_branch_counts(self): 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') + 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') - open(os.path.join(root, 'scripts', 'guard.py'), 'w').write('# guard'); g('add', 'scripts/guard.py'); g('commit', '-qm', 'guard') + 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')) @@ -159,25 +176,32 @@ def test_signoff_is_the_contracts_own_answer(self): 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')) + 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 - json.dump({'deck': 'arcade-contract', 'submitted': '2026-09-01T11:00:00Z', 'answers': {'C': {'v': 'no', 'note': 'R2 is wrong'}}}, open(ap, 'w')) + 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) - json.dump({'deck': 'arcade-contract', 'submitted': '2026-09-01T11:00:00Z', 'answers': {'C': {'v': 'yes'}}}, open(ap, 'w')) + 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) - json.dump({'key': 'x', 'steps': []}, open(os.path.join(d, 'arcade.contract.acceptance.json'), 'w')) + # 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) - json.dump({'submitted': '2026-09-01T12:00:00Z', 'answers': {'C': {'v': 'yes'}}}, open(os.path.join(d, 'arcade.contract.acceptance.answers.json'), 'w')) + 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) @@ -198,7 +222,7 @@ def test_cli_contract_check(self): 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')) + 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]) @@ -230,7 +254,7 @@ def test_builds_the_acceptance_deck(self): 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')) + ap = os.path.join(d, 'arcade.contract.acceptance.json'); write_json(ap, acc) self.assertEqual(validate(load_spec(ap))[0], []) From 96874d02fc618dee254df49983632625cba6dfeb Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:20:42 -0700 Subject: [PATCH 13/24] =?UTF-8?q?feat(close-out):=20a=20Contract=20section?= =?UTF-8?q?=20=E2=80=94=20does=20the=20contract=20hold,=20was=20the=20acce?= =?UTF-8?q?ptance=20deck=20submitted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- scripts/close-out.sh | 33 ++++++++++++++++++ scripts/ui-review/README.md | 1 + .../tests/close-out-contract.test.sh | 34 +++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100755 scripts/ui-review/tests/close-out-contract.test.sh diff --git a/scripts/close-out.sh b/scripts/close-out.sh index 19db94e3..9f58d07e 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,35 @@ 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. +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 + echo echo "Docs" diff --git a/scripts/ui-review/README.md b/scripts/ui-review/README.md index 1ec3c68f..cc9335fa 100644 --- a/scripts/ui-review/README.md +++ b/scripts/ui-review/README.md @@ -269,6 +269,7 @@ Everything (119 tests, ~38s) — needs `magick`, `ffmpeg` and Chrome, all presen 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 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" From 7257b7103e66cecb99f6ea177ad846e5b3766629 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:26:31 -0700 Subject: [PATCH 14/24] feat(deck): the contract agent prompt, dry-run against the arcade's three decks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dry run (fresh agent, inputs = the three arcade specs + answers + the template; output kept in scratch/, not committed). Rows it wrote: R1 deck Connect 4 board fills the pane width, chat below step1-sizing#S-1 R2 deck chess pieces solid (you) vs hollow (opponent) step1-sizing#S-2 (note verbatim) R3 human board square contrast never below 2.0, any theme board-contrast#B-1 R4 live-app friend row shows a win/loss record per game head-to-head#H-1 (note: "pill, 4W - 2L") R5 live-app post-match card shows the head-to-head line head-to-head#H-2 Not covered: no questions deck existed; R3 has no known contrast guard (told not to read code, so left human). Roadmap: none — every note was untagged. contract-check output: ok: contract holds: 5 rows, every source answered and submitted, every guard found todo: not signed — games-arcade.contract.json has no answers file; serve games-arcade.contract.json and answer it todo: acceptance deck not built — write games-arcade.contract.verdicts.json, then review-cards.py acceptance games-arcade.contract.json Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- scripts/ui-review/contract-agent.md | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 scripts/ui-review/contract-agent.md diff --git a/scripts/ui-review/contract-agent.md b/scripts/ui-review/contract-agent.md new file mode 100644 index 00000000..4e0cda99 --- /dev/null +++ b/scripts/ui-review/contract-agent.md @@ -0,0 +1,47 @@ +# 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. From 09ce0286054ed9d299445b00a2f7defd52374a79 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:28:50 -0700 Subject: [PATCH 15/24] 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_01F35AsThZGxRFAARcyurigf --- .claude/rules/feature-flow.md | 63 +++++++++++++++++++++++++++++++ .claude/skills/ui-mockup/SKILL.md | 28 +++++++++++--- CLAUDE.md | 2 +- ROADMAP.md | 2 + docs/MAP.md | 2 +- scripts/ui-review/README.md | 2 +- 6 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 .claude/rules/feature-flow.md diff --git a/.claude/rules/feature-flow.md b/.claude/rules/feature-flow.md new file mode 100644 index 00000000..06ef1561 --- /dev/null +++ b/.claude/rules/feature-flow.md @@ -0,0 +1,63 @@ +--- +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. A note +with no tag (answers files from before 2026-09-01) counts as **just noting**, same as a tagged one. +**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). diff --git a/.claude/skills/ui-mockup/SKILL.md b/.claude/skills/ui-mockup/SKILL.md index 5becf120..f7cc32a3 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,22 @@ 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. 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. diff --git a/CLAUDE.md b/CLAUDE.md index df68e51f..a70225c5 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 — is `.claude/rules/feature-flow.md`. **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..ad1105a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -858,6 +858,8 @@ surface, not a history. ## 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) + - [ ] **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 bd79e838..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 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 | +| 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/scripts/ui-review/README.md b/scripts/ui-review/README.md index cc9335fa..81383a03 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 From 41d7da39eecfe92147adb0628750606d47e97134 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:31:15 -0700 Subject: [PATCH 16/24] =?UTF-8?q?docs(rules):=20feature-flow=20globs=20in?= =?UTF-8?q?=20the=20plain=20workspace-root=20form=20=E2=80=94=20the=20audi?= =?UTF-8?q?t's=20matcher=20needs=20a=20slash=20before=20**/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- .claude/rules/feature-flow.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.claude/rules/feature-flow.md b/.claude/rules/feature-flow.md index 06ef1561..9b58d71a 100644 --- a/.claude/rules/feature-flow.md +++ b/.claude/rules/feature-flow.md @@ -1,10 +1,14 @@ --- paths: - - "**/scripts/ui-review/deck/**" - - "**/scripts/ui-review/review-cards.py" - - "**/scripts/ui-review/contract-agent.md" - - "**/docs/active/design/**" - - "**/scripts/close-out.sh" + # 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 From 1d4193318a409ec0c13af59f068345ae75d22bf8 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:31:42 -0700 Subject: [PATCH 17/24] docs(feature-flow): the design's four assumptions as the first questions deck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Served in the background (--no-open) for Destin to answer; the build proceeds on the design's assumptions and a veto is the first reopen (design §6). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- .../feature-flow.questions.html | 640 ++++++++++++++++++ .../feature-flow.questions.json | 48 ++ 2 files changed, 688 insertions(+) create mode 100644 docs/active/design/2026-09-01-feature-flow/feature-flow.questions.html create mode 100644 docs/active/design/2026-09-01-feature-flow/feature-flow.questions.json 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..4f7237b3 --- /dev/null +++ b/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.html @@ -0,0 +1,640 @@ +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..dc896c60 --- /dev/null +++ b/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.json @@ -0,0 +1,48 @@ +{ + "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 §9", + "headline": "Write a plan document only when work crosses repos, touches a migration or has ordering constraints?", + "options": [ + {"id": "a", "label": "Only for those three cases (recommended)", + "summary": "Otherwise the signed contract plus the approved decks is the plan. The arcade shipped with no plan; the marketplace wrote 3,300 plan lines that were rewritten."}, + {"id": "b", "label": "Always write a plan", + "summary": "More paper per feature, and a plan the reviewers attack instead of the contract."} + ] + }, + { + "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."} + ] + } + ] +} From d38f934ac4f4a4027b049b9ff2285b711661d78f Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:37:11 -0700 Subject: [PATCH 18/24] docs(rules): feature-flow names the skill section as prose, not a checklist guard Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- .claude/rules/feature-flow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/rules/feature-flow.md b/.claude/rules/feature-flow.md index 9b58d71a..98d71691 100644 --- a/.claude/rules/feature-flow.md +++ b/.claude/rules/feature-flow.md @@ -27,7 +27,7 @@ Design: `docs/active/specs/2026-09-01-feature-flow-design.md`. `"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. **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. +**Guard:** `test_words.py`; 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 From bab1fdd3464441dda02760a3d6addb07f555bed5 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:50:08 -0700 Subject: [PATCH 19/24] =?UTF-8?q?fix(feature-flow):=20final-review=20fixes?= =?UTF-8?q?=20=E2=80=94=20contract=20file=20name=20in=20the=20agent=20prom?= =?UTF-8?q?pt,=20statement=20length=20cap,=20validate=20before=20contract-?= =?UTF-8?q?check,=20honest=20counts=20and=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- .claude/rules/feature-flow.md | 4 ++- ROADMAP.md | 2 +- scripts/close-out.sh | 9 ++++++- scripts/ui-review/README.md | 2 +- scripts/ui-review/contract-agent.md | 3 +++ scripts/ui-review/deck/live.py | 4 ++- scripts/ui-review/deck/page.js | 7 ++++- scripts/ui-review/deck/spec.py | 7 +++++ scripts/ui-review/review-cards.py | 14 ++++++++++ scripts/ui-review/templates/contract.json | 2 +- scripts/ui-review/tests/fixture.py | 4 ++- scripts/ui-review/tests/test_contract.py | 31 +++++++++++++++++++++++ 12 files changed, 81 insertions(+), 8 deletions(-) diff --git a/.claude/rules/feature-flow.md b/.claude/rules/feature-flow.md index 98d71691..f2977668 100644 --- a/.claude/rules/feature-flow.md +++ b/.claude/rules/feature-flow.md @@ -26,8 +26,10 @@ Design: `docs/active/specs/2026-09-01-feature-flow-design.md`. **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`; the `ui-mockup` skill's "Before drawing anything" section (prose — not enforced). +**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 diff --git a/ROADMAP.md b/ROADMAP.md index ad1105a9..1497c26a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -858,7 +858,7 @@ surface, not a history. ## 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) +- [ ] **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). `feature` `#workspace` `#ui-review` (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/scripts/close-out.sh b/scripts/close-out.sh index 9f58d07e..e6961728 100755 --- a/scripts/close-out.sh +++ b/scripts/close-out.sh @@ -121,7 +121,14 @@ 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) +# 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 diff --git a/scripts/ui-review/README.md b/scripts/ui-review/README.md index 81383a03..fb9b2904 100644 --- a/scripts/ui-review/README.md +++ b/scripts/ui-review/README.md @@ -262,7 +262,7 @@ The five binary-free suites, which is what CI runs: cd scripts/ui-review/tests && python3 -m unittest test_spec test_tokens test_live test_words test_contract ``` -Everything (119 tests, ~38s) — 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 diff --git a/scripts/ui-review/contract-agent.md b/scripts/ui-review/contract-agent.md index 4e0cda99..a3204e63 100644 --- a/scripts/ui-review/contract-agent.md +++ b/scripts/ui-review/contract-agent.md @@ -45,3 +45,6 @@ answers do not support a row, the row does not exist; write what was missed into - 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/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.js b/scripts/ui-review/deck/page.js index a1bff240..f48f5dbd 100644 --- a/scripts/ui-review/deck/page.js +++ b/scripts/ui-review/deck/page.js @@ -249,7 +249,12 @@ 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'; window.__deckReady = true; return; + 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; diff --git a/scripts/ui-review/deck/spec.py b/scripts/ui-review/deck/spec.py index d7af0272..dc2db279 100644 --- a/scripts/ui-review/deck/spec.py +++ b/scripts/ui-review/deck/spec.py @@ -355,6 +355,13 @@ def _validate_rows(spec, st, sid, errors): 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}"') diff --git a/scripts/ui-review/review-cards.py b/scripts/ui-review/review-cards.py index fa8523a6..ab351354 100644 --- a/scripts/ui-review/review-cards.py +++ b/scripts/ui-review/review-cards.py @@ -94,6 +94,14 @@ def main(argv): 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 @@ -109,6 +117,12 @@ def main(argv): 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: diff --git a/scripts/ui-review/templates/contract.json b/scripts/ui-review/templates/contract.json index e1a7eb33..0c919578 100644 --- a/scripts/ui-review/templates/contract.json +++ b/scripts/ui-review/templates/contract.json @@ -1,7 +1,7 @@ { "title": " — contract", "key": "", - "out": "contract.html", + "out": ".contract.html", "themes": ["midnight"], "branch": "", "sources": { diff --git a/scripts/ui-review/tests/fixture.py b/scripts/ui-review/tests/fixture.py index 27ba54b0..6a9c4056 100644 --- a/scripts/ui-review/tests/fixture.py +++ b/scripts/ui-review/tests/fixture.py @@ -207,7 +207,9 @@ def contract_spec(tmp, **over): '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'], + # 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.', diff --git a/scripts/ui-review/tests/test_contract.py b/scripts/ui-review/tests/test_contract.py index d1f5b920..baa5b3ac 100644 --- a/scripts/ui-review/tests/test_contract.py +++ b/scripts/ui-review/tests/test_contract.py @@ -74,6 +74,14 @@ 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))) @@ -228,6 +236,29 @@ def test_cli_contract_check(self): 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): From ed7a9a32629a47a3ff9a4ff5b4a5e81a85ddf876 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 20:51:29 -0700 Subject: [PATCH 20/24] roadmap: feature-flow coverage debt from the final review Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index 1497c26a..0f11b085 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -859,6 +859,7 @@ 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`. Four assumptions await Destin's veto (design §9). `feature` `#workspace` `#ui-review` (added 2026-09-01) +- [ ] 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. From ee3bb3f1658d243a644b28de5c68beb615ea6ace Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 1 Sep 2026 22:05:50 -0700 Subject: [PATCH 21/24] =?UTF-8?q?docs(feature-flow):=20Destin's=20answers?= =?UTF-8?q?=20to=20the=20four=20assumptions=20=E2=80=94=20Q-1=20picked,=20?= =?UTF-8?q?Q-2/3/4=20'other'=20with=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- .../feature-flow.questions.answers.json | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/active/design/2026-09-01-feature-flow/feature-flow.questions.answers.json 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..a897f67f --- /dev/null +++ b/docs/active/design/2026-09-01-feature-flow/feature-flow.questions.answers.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 From 56d568b2c8ab5ee7d3ff7b410e3d70fcab877edc Mon Sep 17 00:00:00 2001 From: Destin Date: Wed, 2 Sep 2026 03:53:49 -0700 Subject: [PATCH 22/24] =?UTF-8?q?docs(feature-flow):=20=C2=A78=20is=20the?= =?UTF-8?q?=20build=20stage=20=E2=80=94=20technical=20design,=20capped=20s?= =?UTF-8?q?elf-recording=20review,=20task=20breakdown,=20subagent=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design's middle was one line ("draft a plan, reviewers attack it") contradicted by an assumption that said "skip the plan". Destin asked how the backend gets designed, how work is divided, and who checks what the builders are told. §8 now spells out 8a–8d and narrows Q-3 to the one question it is (descriptions vs pre-written code per task). The review loop records each round's findings with accept/reject/reverses marks, stops on a quiet round, caps at three, and is measured after three features — there is no data today on whether rounds improve or churn. Rule, skill step 3, deck Q-3 and ROADMAP follow. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F35AsThZGxRFAARcyurigf --- .claude/rules/feature-flow.md | 9 ++ .claude/skills/ui-mockup/SKILL.md | 9 +- ROADMAP.md | 3 +- .../feature-flow.questions.html | 9 +- .../feature-flow.questions.json | 131 ++++++++++++------ .../plans/2026-09-01-feature-flow-plan.md | 2 +- .../specs/2026-09-01-feature-flow-design.md | 29 +++- 7 files changed, 132 insertions(+), 60 deletions(-) diff --git a/.claude/rules/feature-flow.md b/.claude/rules/feature-flow.md index f2977668..2104b0c1 100644 --- a/.claude/rules/feature-flow.md +++ b/.claude/rules/feature-flow.md @@ -61,6 +61,15 @@ submitted. `close-out.sh` relays its `ok:` / `todo:` lines and reads no answers 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 diff --git a/.claude/skills/ui-mockup/SKILL.md b/.claude/skills/ui-mockup/SKILL.md index f7cc32a3..550a8483 100644 --- a/.claude/skills/ui-mockup/SKILL.md +++ b/.claude/skills/ui-mockup/SKILL.md @@ -95,9 +95,12 @@ Decisions must not live only in chat — and the deck answers ARE the record (th 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. +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 diff --git a/ROADMAP.md b/ROADMAP.md index 0f11b085..7ca6780b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -858,7 +858,8 @@ 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`. Four assumptions await Destin's veto (design §9). `feature` `#workspace` `#ui-review` (added 2026-09-01) +- [ ] **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; §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) 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 index 4f7237b3..11646b15 100644 --- 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 @@ -212,7 +212,7 @@
- +