diff --git a/.gitignore b/.gitignore index e43b0f9..cc3314d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +.playwright-mcp/ diff --git a/AI/mcp-setup.md b/AI/mcp-setup.md index 7c32cf6..5760b1a 100644 --- a/AI/mcp-setup.md +++ b/AI/mcp-setup.md @@ -8,9 +8,9 @@ | Integration | Provides | Required by | |-------------|----------|-------------| -| **Notion MCP** | BRD read/write: `notion-search`, `notion-fetch`, `notion-update-page`, `notion-query-data-sources`, `notion-create-pages` | orchestrator + every stage (hard requirement) | -| **GitHub (`gh` CLI)** | branch/PR/CI/merge: `gh pr create/view/checks/merge` | git, code-review, release stages | -| **Figma MCP** | design context, screenshots, design-system reads | ui-workflow, design-system-workflow — only when the BRD references Figma | +| **Notion MCP** | BRD read/write: `notion-fetch`, `notion-update-page`, `notion-query-data-sources`, `notion-create-pages` — registered databases/pages only, no workspace search ([integration-map](../Architecture/integration-map.md) §2b) | orchestrator + every stage (hard requirement) | +| **GitHub (`gh` CLI)** | branch/PR/CI/merge: `gh pr create/view/checks/merge` — bound repositories only | git, code-review, release stages | +| **Figma MCP** | design context, screenshots, design-system reads, navigation-map writes — bound file keys only | ui-workflow, design-system-workflow, flow-visualization — only when `resources.figma.product_design_file` (or the design-system library) is bound in the Project Resource Registry. Unbound → flow-visualization degrades loudly to the markdown map, never silently skips | | **Local shell/files** | build, test, serve prototype | implementation, qa, debug, prototype Run Local | ## 2. Setup Checks (per machine, once) diff --git a/AI/model-routing.md b/AI/model-routing.md index 4c9c2f9..91a5889 100644 --- a/AI/model-routing.md +++ b/AI/model-routing.md @@ -27,6 +27,11 @@ Pin to latest within each family; exact IDs live here so projects inherit update | Design: UI planning (06) | T2 | DS mapping against explicit inventory | | Design: Prototype (07) | T2 | Assembly against spec; DS check is procedural | | Design: Self-audit (08) | **T3** | Adversarial pass; catches what the builder can't (§5 cross-model rule) | +| Design Review: user review (09) | T2 | Packaging + capture; the judgment is the user's | +| Design Review: revision triage (10) | **T3** | Root-cause routing — a misroute costs three cycles, and did | +| Design Review: final output (11) | T2 | Freeze, hash and completeness are mechanical against the matrix | +| Design: flow visualization (12) | T2; **T3** for the first derivation's reconciliation and the state-vocabulary normalization | Derivation is a tool; deciding registry-vs-flows disagreements is not | +| Security certification (`C_SECURITY`) | **T3** | Adversarial, and a miss ships to production (§6 T4 reserve applies to auth/payment/PII scope) | | Dev Planning (FE/BE) | **T3** | Architecture + contracts freeze here; rework cost peaks | | Implementation | T2; T1 for mechanical slices (boilerplate, config, copy, codemods) | Plan quality already bought; assembly is standard work | | QA | T2 | Procedural verification + exploratory judgment | @@ -43,7 +48,7 @@ Every S16 stage-enter entry appends the model: `— Stage-Enter (model: T2/sonne ## 4. Escalation Rules 1. **Retry escalation:** a stage failing its exit checklist twice at its default tier re-runs at +1 tier, with the failed rules as corrective constraints. (First retry stays at-tier — most failures are attention, not capability.) -2. **Loop escalation:** re-entry via a loop (`L_QA`, `L_REVIEW`, `L_DESIGN` second iteration onward) escalates the **producing** stage's model +1 tier — the bounce is evidence the work needed more depth, not more speed. +2. **Loop escalation:** re-entry via a loop (`L_QA`, `L_REVIEW`, `L_DESIGN`/`L_REVISION` second iteration onward) escalates the **producing** stage's model +1 tier — the bounce is evidence the work needed more depth, not more speed. 3. **Debug escalation:** T2 → T3 when 2 hypotheses die without narrowing the mechanism, or at half the timebox — whichever first. 4. **No mid-stage downgrade.** Tier changes happen at stage boundaries only; a stage finishes at the tier it escalated to. 5. **De-escalation:** next BRD's same stage returns to the table default — escalations don't ratchet permanently; recurring escalation of the same stage across BRDs = routing-table bug, fix it here (minor bump). diff --git a/AI/orchestrator.md b/AI/orchestrator.md index 32ec3cf..4c22f00 100644 --- a/AI/orchestrator.md +++ b/AI/orchestrator.md @@ -7,11 +7,20 @@ ## 1. Responsibilities -0. **Manifest gate** — before ANY project work: `C_MANIFEST` ([../Architecture/project-manifest.md](../Architecture/project-manifest.md)). Missing/incomplete manifest → refuse BRD work, offer [project-onboarding](../Workflows/project-onboarding.md). Stale `last_validated` → run [integration-validation](../Workflows/integration-validation.md) first. Project facts come from the manifest — never re-ask the user for manifest-held values. +0. **Manifest Gate** — at session entry, before ANY project work (pickup **and** resume alike). A fixed pipeline; each step's failure action is the only permitted next move: + 1. Load the **Toolkit Registry** (`~/.toolkit/registry.yaml`, [../Architecture/toolkit-registry.md](../Architecture/toolkit-registry.md)). Missing → offer one-time setup (README); nothing else runs. + 2. Load `project-manifest.yaml`. Missing / `onboarding.status: incomplete` → refuse BRD work, offer [project-onboarding](../Workflows/project-onboarding.md). + 3. `manifest_version` older than current → run **Migration** ([project-onboarding §Migration](../Workflows/project-onboarding.md)) — automatic offer, seeded from the old fields, resumable; runs **before** staleness/validation (those need registry ids the old manifest lacks). In-flight BRDs resume normally once it completes. + 4. `last_validated` stale (>30 days) → run [integration-validation](../Workflows/integration-validation.md). + 5. `C_MANIFEST` full check ([../Architecture/workflow-state-machine.md](../Architecture/workflow-state-machine.md) §4) → fail: report exactly what unblocks. + Project facts come from the manifest — never re-ask the user for manifest-held values. +0b. **Resource boundary** (v1.5 — [Project Boundary Rule](../Architecture/integration-map.md) §2b). All external access resolves through the Project Resource Registry by **stable identifier**. Never workspace-search Notion, browse Figma, or list repositories; never guess a resource. A stage requiring a slot that is missing, skipped, or `health: unreachable` → raise a **Resource Decision** ([project-manifest §3](../Architecture/project-manifest.md)): *connect existing / create new / confirm absence*. While it is pending, set `Status: Blocked`, `Blocked Reason: resource: ` — this makes the stop resumable at session entry (§2) and fires the Telegram failure trigger (responsibility 9). Log the decision S16; clear `Blocked` on resolution. 1. **Pickup** — select next BRD from `Ready` (priority order) when a slot is free (<3 in-flight) and `C_MANIFEST` holds for its project. 2. **Stage routing** — map `Status` → workflow module → skill → model tier per [model-routing.md](model-routing.md); load only what the stage needs; log the model in the Stage-Enter S16 entry. 3. **Input verification** — before running a stage, check `C_SECTIONS(required)`: required BRD sections exist and are non-empty. Missing input → back-transition to the producing stage, never improvise the input. -4. **Gate enforcement** — never cross a human gate without the approval token in `Approvals`; revoke tokens when gated content changes (stale-approval rule). At Dev Planning entry additionally run `C_CONTRACT` ([../Checklists/screen-contract.md](../Checklists/screen-contract.md)): any missing mapping → stop, report `SCR-id · block · gap` lines, route to the owning stage, log S16. No implementation on an incomplete Screen Contract. +4. **Gate enforcement** — never cross a human gate without the approval token in `Approvals`; revoke tokens when gated content changes (stale-approval rule; classify the delta first — bug-fix-only → scope confirm with byte-level evidence, feature delta → a ruling). On `Design Gate` approval, evaluate `C_HANDOFF_REQUIRED` ([../Architecture/workflow-state-machine.md](../Architecture/workflow-state-machine.md) §4): true → run design state 12 ([../Workflows/flow-visualization.md](../Workflows/flow-visualization.md)) and hold the BRD in `Design Review` until the **Developer Handoff Gate** resolves; false → log the skip in S16 and continue. At **QA entry and again at Tech Review entry** run `C_SECURITY` ([../Checklists/security.md](../Checklists/security.md)): S14 must carry a `certified` Security Certificate whose `certified_commit` equals the current branch head — stale or missing → stop, route to [../Workflows/security-certification.md](../Workflows/security-certification.md), log S16. No QA on uncertified code, no review on a certificate that predates the fixes. At Dev Planning entry additionally run `C_CONTRACT` ([../Checklists/screen-contract.md](../Checklists/screen-contract.md)): any missing mapping → stop, report `SCR-id · block · gap` lines, route to the owning stage, log S16. No implementation on an incomplete Screen Contract. +4b. **Check evidence, not claims** — where a gate's evidence is a tool run ([../Architecture/validation-engine.md](../Architecture/validation-engine.md)), read the **exit code**: `0` pass, `1` findings, `2` **the check did not run** — *unevaluable*, never a pass. A stage reporting "checks passed" with no exit code recorded has not produced gate evidence. + 5. **Loop accounting** — increment `Loop Count` before re-entry; enforce ceilings; on breach set `Blocked` + escalation summary in S16, never loop silently. 6. **Notion updates** — advance `Status`, set `Stage Owner`, write the S16 transition entry after every transition (atomic: status + log together). 7. **Resume** — reconstruct everything from Notion properties + S16. Session memory is never machine state. @@ -26,7 +35,8 @@ On any session start (or `resume ` request): -1. Query the BRD DB: in-flight pages (Status ∈ Analysis…Human Review) + `Ready` pages, filtered to this project unless told otherwise. +0. Run the **Manifest Gate** (responsibility 0, all five steps). Only after it passes may any external system be touched. +1. Query the BRD DB — via the registry binding (`resources.notion.brd_database.id`): in-flight pages (Status ∈ Analysis…Human Review) + `Ready` pages, filtered to this project unless told otherwise. 2. If a specific BRD named → load it. Else: continue oldest in-flight first; pick up new `Ready` BRDs only when slots free and user confirms pickup. 3. Read BRD properties + S16 tail (last 10 entries) → determine exact machine position, pending gates, open loops. 4. Announce: BRD, stage, pending gates/blockers, planned action. Then run the stage. @@ -42,6 +52,9 @@ ENTER verify C_SECTIONS(stage.inputs) → missing? back-transition RUN load Workflows/.md + Skills/.md + referenced Standards/ act ONLY with that role's permission-matrix rights write findings to BRD immediately when discovered (living doc), not at exit + design fidelity: when a PO handoff skips the Design Gate, schedule the + fidelity review (ui-workflow M7, a reviewer who did not build the screen) + BEFORE device proof — never skipped silently; the skip would be logged S16 EXIT run stage exit checklist (Checklists/ when built; workflow Completion Criteria until then) pass → write outputs summary + S16 [Stage-Exit] entry → advance Status @@ -57,9 +70,9 @@ GATE if stage exits through a human gate: present decision package When a human gate is pending, present exactly: 1. **BRD** — ID, name, link. -2. **Gate** — which (Direction / Design / Final) and what approval unlocks. -3. **Review target** — Direction: S01–S06 summary. Design: running prototype URL (Run Local, port 8765 default) + S07–S09. Final: PR link + diff summary + S13/S14 verdicts. -4. **Known limitations** — from audits, transparently. +2. **Gate** — which (Direction / Design / **Developer Handoff** / Final) and what approval unlocks. +3. **Review target** — Direction: S01–S06 summary. Design: running prototype URL (Run Local, port 8765 default) + the **deep-link hook table** + S07–S09. Developer Handoff: the **derivation report** (`navmap-report.md`) — never the picture — plus registry sha, derivation run and prototype versions. Final: PR link + diff summary + S13/S14 verdicts. +4. **Known limitations** — from audits, transparently, at full strength. Every waiver names its rider debt item, grantor and closing condition; an acceptance with qualifications is recorded with its qualifications. 5. **Ask** — `approve` / `request-changes` (structured, each with target) / `reject` / `stop`. Multiple pending gates across parallel BRDs → batch, oldest first. @@ -82,3 +95,5 @@ Multiple pending gates across parallel BRDs → batch, oldest first. - Never advances Status without the exit check passing. - Never carries approvals across content changes. - Never holds state only in conversation. If it isn't in Notion, it didn't happen. +- Never lands a screen whose Design block lacks reading order / chrome / alignment for its bound frame, or that deviates from the frame on arrangement or hierarchy without a recorded Product Owner ruling (screen-contract §4a). +- Never touches a resource outside the Project Resource Registry — no workspace searches, no repo listing, no unregistered files. Missing resource → connect/create offer, never a guess (responsibility 0b). diff --git a/Architecture/brd-schema.md b/Architecture/brd-schema.md index 5848f9d..74a4404 100644 --- a/Architecture/brd-schema.md +++ b/Architecture/brd-schema.md @@ -56,7 +56,7 @@ Sections are H2 headings in the page body, in this order. Each heading carries i | S11 | Component Plan & API Notes | revise | Files/components to create or modify; API contracts | | S12 | Implementation Notes & Progress | append | Dated progress entries; deviations from plan with reason | | S13 | Test Cases, Bugs & Verification | revise | Test cases mapped to ACs; bug list with severity; verification status per AC | -| S14 | Review Summary & Approval | revise | Review findings, concerns, recommendations, approval status | +| S14 | Review Summary & Approval | revise | Review findings, concerns, recommendations, approval status. **Subsections (append-only, each owned by its issuing role):** `design-audit` (design state 08), `security-certificate` (`C_SECURITY`, before QA — [template](../Templates/security-certificate.md)), `handoff-gate` (design state 12, when in scope) | | S15 | Release Notes | revise | User-facing change summary; version; date | | S16 | Decision Log | append-only | See format below. Never edited or deleted, only appended | diff --git a/Architecture/context-package.md b/Architecture/context-package.md index a3e413b..04d1a28 100644 --- a/Architecture/context-package.md +++ b/Architecture/context-package.md @@ -1,6 +1,6 @@ # AI Context Package -> **Module:** Architecture / Foundation (v1.3) +> **Module:** Architecture / Foundation (v1.5) > **Status:** Stable > **Purpose:** `context/` — small generated summaries a session reads at bootstrap for consistent AI behavior, without loading the full toolkit. **Generated, never hand-edited.** The manifest and toolkit stay authoritative; `context/` is a derived cache. @@ -10,7 +10,7 @@ | File | Summarizes | Source of truth | |------|-----------|-----------------| -| `design.md` | Design config: Figma refs (or prototype-only mode), component library, token source, icon/typography, DS state + where the DS lives | manifest `design.*` + DS docs | +| `design.md` | Design config: Figma bindings (or prototype-only mode), component library, token source, icon/typography, DS state + where the DS lives | manifest `design.*` + `resources.figma.*` + DS docs | | `stack.md` | Stack + resolved profile + the exact list of applicable Standards/ files + declared gaps | manifest `stack.*` + [stack-profiles](stack-profiles.md) | | `integrations.md` | Last validation results per integration + per-stage degradation consequences of anything unavailable | manifest `integrations.*` + [../Workflows/integration-validation.md](../Workflows/integration-validation.md) | | `conventions.md` | Naming/branch/commit/PR contracts resolved for this project + pointer to `project-overrides.md` entries | manifest `git.*` + [integration-map](integration-map.md) §3 + overrides | diff --git a/Architecture/design-state-machine.md b/Architecture/design-state-machine.md index b7bb29f..16bb4bf 100644 --- a/Architecture/design-state-machine.md +++ b/Architecture/design-state-machine.md @@ -2,18 +2,23 @@ > **Module:** Architecture / Foundation > **Status:** Stable -> **Source:** Adapted from "AI Product Design Agent — Workflow Architecture" (UI UX - Workflow.md). Gates, validation rules, retry ceilings, and loop bounds preserved. Artifact store remapped to the Living BRD. -> **Consumed by:** Workflows/ux-workflow, Workflows/ui-workflow, Workflows/design-system-workflow, AI/orchestrator +> **Normative process source:** [`../design-toolkit/docs/workflow.md`](../design-toolkit/docs/workflow.md) — vendored @ `4081c24`, see [VENDORED.md](../design-toolkit/VENDORED.md). Where this file and the vendored spec disagree about **process**, the spec wins. Where they disagree about **where artifacts live**, this file wins (§1). +> **Consumed by:** Workflows/ux-workflow, Workflows/ui-workflow, Workflows/design-review, Workflows/flow-visualization, Workflows/design-system-workflow, AI/orchestrator +> +> **The executable procedure is in the Workflows, not here.** Each vendored skill is **cloned in full** into its workflow file — this file is the machine (states, guards, gates, remapping), those are the method: +> [ux-workflow](../Workflows/ux-workflow.md) 04–05 · [ui-workflow](../Workflows/ui-workflow.md) 06–08 · [design-review](../Workflows/design-review.md) 09–11 · [flow-visualization](../Workflows/flow-visualization.md) 12 -The sub-machine that runs **inside** lifecycle states `Design` → `Design Review` (see [workflow-state-machine.md](workflow-state-machine.md)). Lifecycle states `Analysis`/`Planning` already execute design states 01–03; this file keeps the full catalog so the design flow is auditable end-to-end. +The sub-machine that runs **inside** lifecycle states `Design` → `Design Review` (see [workflow-state-machine.md](workflow-state-machine.md)), plus one conditional state on the `Design Review → Dev Planning` edge. Lifecycle states `Analysis`/`Planning` already execute design states 01–03; this file keeps the full catalog so the design flow is auditable end to end. + +**Twelve states, not eleven.** State 12 `FLOW_VISUALIZATION` is numbered by authoring order, not machine order: it runs *after* state 09 approves and *before* state 11 packages, and only when `C_HANDOFF_REQUIRED` holds. --- -## 1. Artifact Remapping (source doc → Living BRD) +## 1. Artifact Remapping (vendored spec → this toolkit) -Feature knowledge lives in Notion (single source of truth). Only build artifacts live in the repo. +Feature knowledge lives in Notion (single source of truth). Only build artifacts live in the repo. The vendored spec's `artifacts/*.md` store is **not used**; every artifact below has exactly one home here. -| Source artifact | Lives in | +| Vendored artifact | Lives in | |-----------------|----------| | `requirements.md` | BRD S03 (+ S04 assumptions) | | `research.md` | BRD S05 | @@ -21,16 +26,52 @@ Feature knowledge lives in Notion (single source of truth). Only build artifacts | `ux-plan.md` | BRD S07 + S09 | | `flows.md` | BRD S07 (flow subsection; graphs as embeds/links) | | `ui-plan.md` | BRD S08 | -| `prototype/` + `traceability.md` | **Repo**: `design/prototype//` on the BRD branch; link recorded in S08 | +| `prototype/` + `traceability.md` | **Repo**: `design/prototype//` on the BRD branch; link + version id recorded in S08 | | `audit-report.md` | BRD S14 (design-audit subsection) | | `review-record.md` | BRD S14 + `Approvals` property + S16 | | `revision-log.md` | BRD S16 (Decision Log entries, tagged `[Revision]`) | +| `navgraph.json` · `navmap-report.md` | **Repo**: `design/navmap//` — derived, regenerated, committed | +| `flow-visualization.md` | BRD S07 (navigation subsection) + the design file when Figma is bound | | `deliverable/` | Approved prototype (repo, frozen commit) + S07–S09 as handoff spec | | `machine_state` | Notion properties: `Status`, `Stage Owner`, `Approvals`, `Loop Count`, `Blocked Reason` | -## 2. State Catalog (condensed; validations normative) +**Reference inputs** the vendored spec expects the product to own: + +| Vendored reference input | This toolkit's home | +|---|---| +| `reference/screen-registry.csv` | **[Screen Contract](screen-contract.md)** — `screens/registry.md` + `screens/SCR-.md`. State 12 derives from the Screen Contract; no parallel CSV is created. | +| `reference/nav-lanes.json` · `state-vocabulary.md` · `state-machines.json` · `edge-annotations.json` | `design/navmap/` in the project repo, seeded from [`../design-toolkit/templates/`](../design-toolkit/templates/) | +| design-system reference | Project DS in the repo + Figma library when bound ([project-manifest](project-manifest.md) §3) | +| `toolkit.config.json` | `project-manifest.yaml` `design:` block; the vendored [`toolkit.config.json`](../design-toolkit/toolkit.config.json) is the field reference | + +## 2. Artifact Discipline (from `artifact-contracts.md`) + +Applies to every design artifact, wherever §1 puts it: + +- **Versions increment, never overwrite.** A revision creates a new version and names what it supersedes. One prefix per artifact, fixed — a gate record that cannot resolve a version id is the defect `reads_versions` exists to prevent: -Mapping to lifecycle: states 01–02 run under `Analysis`, state 03 under `Planning`, states 04–08 under `Design`, state 09 = `Design Review`, state 10 routes per target, state 11 exits into `Dev Planning`. +| Artifact | Version id | Written by | +|---|---|---| +| requirements (S01–S04) | `req--NN` | state 01 | +| research (S05) | `res--NN` | state 02 | +| product review (S03/S06) | `pr--NN` | state 03 | +| ux plan (S07/S09) | `ux--NN` | state 04 | +| flows (S07) | `flow--NN` | state 05 | +| ui plan (S08) | `ui--NN` | state 06 | +| prototype | `proto--NN` | state 07 | +| traceability | `trace--NN` | state 07 | +| design audit (S14) | `audit--NN` | state 08 | +| gate record (S14) | `review--NN` | state 09 | +| revision entry (S16) | `rev--NN` | state 10 | +| navigation map | `navmap--NN` | state 12 | +| security certificate (S14) | `cert--NN` | `C_SECURITY` | +- **`reads_versions` is load-bearing.** Every audit verdict and gate record names the *exact* versions it consumed — not "the latest". This is the field `FINAL_OUTPUT` checks completion rule 2 against; a record that names its inputs only in prose is not machine-checkable. +- **A gate record carries sha256** of every approved file plus the player URL the review was actually conducted at. An approval is scoped to the bytes it saw (`G3`, `M5`, `P1`). +- **A freeze is a hash, not a copy** (`P2`). A screen in no frozen deliverable is not delivered, however finished it looks. + +## 3. State Catalog (condensed; validations normative) + +Lifecycle mapping: 01–02 under `Analysis`, 03 under `Planning`, 04–08 under `Design`, 09 = `Design Review`, 10 routes per target, **12 on the `Design Review → Dev Planning` edge when `C_HANDOFF_REQUIRED`**, 11 exits into `Dev Planning`. ### 01 `REQUIREMENT_ANALYSIS` — under lifecycle `Analysis` Converts raw request → S01–S04. Validations: ≥1 goal + ≥1 AC; every requirement has a falsifiable AC; no item both `assumed` and `confirmed`; open questions carry severity (`blocking`/`non-blocking`). Exit: valid AND no unresolved blocking question. **Clarification Gate** on blocking ambiguity. Retry ceiling 3 → `Blocked`. @@ -45,36 +86,151 @@ Proceed / re-scope / stop with rationale → S03 prioritized, S06 scored, S16 de UX strategy, no screens → S07 (tasks, IA) + S09 (edge-case matrix). Validations: every primary task has happy path + **≥3 non-happy-path states** (error, empty, loading, interrupted, offline, permission-denied as applicable); accessibility + reduced-motion strategy present; every task traces to a prioritized S03 requirement; no visual design content. Retry ceiling 2 → `partial-coverage` flag only if risk tolerance in S06 allows. ### 05 `FLOW_GENERATION` — under lifecycle `Design` -Flows + transitions → S07 flow subsection. Validations: no unreachable state; no dead end without terminal justification; every S09 non-happy-path state has a recovery route; every decision point has mutually exhaustive branch conditions. Retry ceiling 3; persistent unreachable state → back to `UX_PLANNING`. +Flows + transitions → S07 flow subsection. Validations: no unreachable state; no dead end without terminal justification; every S09 non-happy-path state has a recovery route; every decision point has mutually exhaustive branch conditions. Retry ceiling 3; persistent unreachable state → back to `UX_PLANNING`. **Screen-free by construction** — state 05 rules what a flow *is*; state 12 proves the design file says so. ### 06 `UI_PLANNING` — under lifecycle `Design` -Component inventory + DS mapping → S08. Validations: every flow state maps to a component set; DS reuse preferred — every `new` component justified; no one-off styling where a DS primitive exists; token references resolve to the project design system or are flagged as **Extension Note**. Retry ceiling 2. +Component inventory + DS mapping → S08; screens registered in the Screen Contract. Validations: every flow state maps to a component set; DS reuse preferred — every `new` component justified; no one-off styling where a DS primitive exists; token references resolve to the project design system or are flagged as **Extension Note**. Name the DS by source id — *a plan built on the wrong design system validates perfectly against it*. Retry ceiling 2. ### 07 `PROTOTYPE` — under lifecycle `Design` -Assemble prototype in repo (`design/prototype//`). **Mandatory pre-build gate: check the design system first** — load DS reference, confirm needed tokens/primitives/components exist; reuse, never invent ad-hoc values; genuine gap → Extension Note in S08. Wire all transitions incl. recovery routes. Produce traceability (element → S07/S08 entry + DS token used). Validations: every flow state represented; every element traces to spec (no un-specced additions); DS token usage matches S08; wired transitions match flows; pre-build DS check performed. Retry ceiling 3 → back to `UI_PLANNING` on spec insufficiency. +Assemble prototype in repo (`design/prototype//`). **Mandatory pre-build gate: check the design system first** — load DS reference, confirm needed tokens/primitives/components exist; reuse, never invent ad-hoc values; genuine gap → Extension Note in S08. Wire all transitions incl. recovery routes. Produce traceability (element → S07/S08 entry + DS token used). Validations: every flow state represented; every element traces to spec (no un-specced additions); DS token usage matches S08; wired transitions match flows; pre-build DS check performed. **Build rules `B1`–`B8`, Figma traps `F1`–`F3`** ([method-rules](../design-toolkit/docs/method-rules.md)) — in particular: every state ships a **deep-link hook** recorded in traceability (`B2`), the token layer is the base and the base font stack is checked per script (`B5`), a transition is not wired until its **destination paints** (`B6`), supersession **deletes** (`B7`). Retry ceiling 3 → back to `UI_PLANNING` on spec insufficiency. ### 08 `SELF_AUDIT` — under lifecycle `Design` -Machine self-review before spending human attention → S14 design-audit subsection. Checks: conformance S03→S07→S08→prototype; accessibility + reduced-motion audit executed (never skipped); non-happy-path coverage reachable; findings classified `blocker`/`major`/`minor`; every AC marked met/unmet with evidence; verdict `pass`/`fail` with rationale. `pass` → lifecycle `Design Review`. `fail` → state 10 REVISION. Audit-fix loop ceiling 3. +Machine self-review before spending human attention → S14 design-audit subsection. Checks: conformance S03→S07→S08→prototype; accessibility + reduced-motion audit executed (never skipped); non-happy-path coverage reachable; findings classified `blocker`/`major`/`minor`; every AC marked met/unmet with evidence; verdict `pass`/`fail` with rationale. **Verification rules `M1`–`M6`**: every check is **rendering-class** (computed visibility + geometry, never DOM presence); **look at the render** — screenshots across language × theme × reduced-motion × state; a failing probe is a **hypothesis** — confirm at source, fix the instrument, re-run, never waive unconfirmed; sweep the **source** (duplicate keys, stale placeholder routes, per-glyph font fallback); the verdict is scoped to the bytes it audited. `pass` → lifecycle `Design Review`. `fail` → state 10 REVISION. Audit-fix loop ceiling 3. ### 09 `USER_REVIEW` — lifecycle `Design Review` (human) -**Run Local default:** serve prototype over local HTTP (`design/prototype//run-local.sh [port]`, default 8765; reuse listening server). Review happens against the running prototype, player URL recorded in S14. Present limitations from the audit transparently. Capture `approve` / `request-changes` / `reject`; structure change requests, each linked to a target state; none silently dropped. **Design Gate.** Approve → state 11. Request-changes → state 10. Reject → lifecycle `Analysis`. User unavailable → `Blocked` (resumable). +**Run Local, never a static preview** (`G1`): serve the prototype over local HTTP (`design/prototype//run-local.sh [port]`, default 8765; reuse a listening server), review through the player, record the player URL in S14. **The hook list is the packet** (`G2`) — ship state 07's traceability table with the verdict request. Present limitations at full strength; an acceptance with qualifications is recorded with its qualifications (`G5`). Capture `approve` / `request-changes` / `reject`; structure change requests, each linked to a target state; none silently dropped. Every waiver names its rider debt item, grantor and closing condition (`G6`). Ambiguity is bounded to two clarification rounds, then recorded as a non-blocking note (`G8`). **Design Gate.** Approve → state 12 if `C_HANDOFF_REQUIRED`, else state 11. Request-changes → state 10. Reject → lifecycle `Analysis`. User unavailable → `Blocked` (resumable). ### 10 `REVISION` -Merge audit findings + change requests into one change set; triage each to its **root-cause state** (requirement? flow? UI? prototype?); order upstream-first; dispatch; re-run downstream; every item ends `resolved` or `deferred` with reason — never silently `open`. Dedupe against S16 `seen` entries so rejected changes don't re-enter. Loop ceiling: **3 full revision cycles** → `Blocked` + escalation summary. Conflicting change requests → mini-gate to user before dispatch. +Merge audit findings + change requests into one change set; dedup against S16 `seen` entries (`R1`); triage each to its **root-cause state** — where the fault was *introduced*, not where it is visible (`R3`); **route the class, not the instance** — state the class, sweep for it, record the sweep count (`R2`); dispatch a **bounded scope** naming what changes *and what must not* (`R4`); re-verify aged items against current bytes before dispatch (`R5`); re-validate through `SELF_AUDIT`, or record a waiver with a rider (`R6`); **count the loop out loud every cycle** — one round = one prototype rebuild (`R7`). Every item ends `resolved` or `deferred` with reason — never silently `open`. Loop ceiling: **3 full revision cycles** → `Blocked` + escalation summary. Conflicting change requests → **Conflict Mini-Gate** before dispatch, never resolved in the bytes (`R8`). + +### 12 `FLOW_VISUALIZATION` — on the `Design Review → Dev Planning` edge, when `C_HANDOFF_REQUIRED` +Proves the approved design is *ready for development*, not merely visually complete → `design/navmap//` + S07 navigation subsection + design-file Sections when Figma is bound. **Derive the graph, never draw it** (`W1`) — from the Screen Contract registry, by tool, with an exit code; reconcile against the ratified S07 flows, and record disagreements as findings rather than merging them. One Section per **journey**, `FLOW-XXX • Journey Name` (`W2`); layout is a contract — left→right, uniform pitch, 8pt grid, branches vertical (`W3`); the legend ships in the file (`W4`); connectors regenerate **wholesale**, never hand-patched (`W5`); every frame carries its own metadata including the prototype version it depicts (`W6`); sync is triggered by a **hash diff of the edge set**, not by memory (`W7`); a boundary is a **dated claim** (`W8`); state variants are not optional (`W9`); **the gate passes on the report, not on the picture** (`W10`). Extensions E1–E7: lanes from an explicit assignment file (unassigned → reported, never guessed) · derived cross-feature map · **measured** heat (in-degree + distinct source features) · deep links read out of the implementation, not out of a doc claiming them · normalized state vocabulary before generation · `UNKNOWN` legal, a guessed value is not · provenance block on the overview page. Validations V1–V13 per the vendored skill. **Developer Handoff Gate.** Retry ceiling 3 → back to `FLOW_GENERATION`; registry ↔ prototype route conflict → state 10. ### 11 `FINAL_OUTPUT` — exits into lifecycle `Dev Planning` -Verify approval current (not superseded); freeze prototype commit; S07–S09 constitute the handoff spec; final completeness check: 100% design-relevant ACs met or user-waived. Completeness regression → back to REVISION, never silent shipping. +Verify approval current, and that it **names its bytes** (`P1`); freeze the prototype commit — a freeze is a hash (`P2`); the audit of record must have run on the bytes being frozen (`P4`); S07–S09 constitute the handoff spec; final completeness check against the **traceability matrix**, never from memory (`P3`) — 100% design-relevant ACs `met` or user-waived, and `superseded` is a legitimate status that names its superseding revision. A waiver is a legitimate exit; silence is not (`P6`). **Known limitations ship inside the deliverable at full strength** (`P8`). Close the machine record in the same edit as the freeze (`P7`). Completeness regression → back to REVISION, never silent shipping. + +## 4. Transitions (design-machine edges that leave a lifecycle stage) + +| From | Trigger / condition | To | +|---|---|---| +| `SELF_AUDIT` | verdict `pass` | lifecycle `Design Review` (state 09) | +| `SELF_AUDIT` | verdict `fail` | `REVISION` | +| `USER_REVIEW` | `approve` ∧ `C_HANDOFF_REQUIRED` | `FLOW_VISUALIZATION` | +| `USER_REVIEW` | `approve` ∧ ¬`C_HANDOFF_REQUIRED` | `FINAL_OUTPUT` | +| `USER_REVIEW` | `request-changes` | `REVISION` | +| `USER_REVIEW` | `reject` | lifecycle `Analysis` | +| `FLOW_VISUALIZATION` | Developer Handoff Gate granted ∧ `C_NAVMAP_CLEAN` | `FINAL_OUTPUT` | +| `FLOW_VISUALIZATION` | registry route no flow graph ratified | `FLOW_GENERATION` | +| `FLOW_VISUALIZATION` | registry ↔ prototype route conflict | `REVISION` | +| `FINAL_OUTPUT` | validation pass | lifecycle `Dev Planning` | +| `FINAL_OUTPUT` | completeness regression | `REVISION` | -## 3. Retry vs Back-Transition +Full internal edge list: [vendored spec §3](../design-toolkit/docs/workflow.md). + +## 5. Retry vs Back-Transition - **Retry (self-loop):** fault is inside this state's output — fix here, `entry_count` +1, ceiling per state above. - **Back-transition:** root cause is upstream — route to the owning state. Missing input artifact → back to producing state. Tool/runtime error → retry step ×2 → `Blocked` with diagnostic. -## 4. Deltas from Source Doc +## 6. Conditions + +| Condition | Definition | +|---|---| +| `C_AUDIT_PASS` | S14 design-audit verdict = `pass`, zero `blocker`, run on the current prototype version | +| `C_ALL_CRITERIA_MET` | 100% design-relevant ACs `met`, `waived` (recorded), or `superseded` (naming the revision) | +| `C_HANDOFF_REQUIRED` | The design goes to a build audience that was not in the room, so the navigation map is in scope. Source: `project-manifest.yaml` `design.handoff_required` (**default `false`**), overridable per BRD via a `Handoff Required` property. Set `true` for design-only engagements resuming into build later, multi-flow features, and any BRD whose builder is not its designer. | +| `C_NAVMAP_CLEAN` | Navigation derivation exits clean at the configured severity, or every remaining finding carries a granted waiver + rider debt item | + +## 7. Gates + +| Gate | Location | Blocks | Grantor | +|---|---|---|---| +| Clarification | state 01 | leaving Analysis with blocking ambiguity | user | +| **Direction** | state 03 → 04 | spending design effort on unapproved direction | user | +| **Design** (primary) | state 09 | shipping an unapproved design | user | +| Conflict Mini-Gate | state 10 | dispatching conflicting change requests | user | +| **Developer Handoff** | state 12 → 11 | shipping a design a build team cannot navigate from | user | + +An approval is scoped to the artifact versions it saw. Artifacts change after approval → the gate reverts to `pending` (stale-approval rule, [workflow-state-machine](workflow-state-machine.md) §6). Classify the delta before asking about it: bug-fix-only → scope confirm with byte-level evidence; feature delta → a ruling (`G4`). + +## 8. Loops & Ceilings + +| Loop | Path | Ceiling | On breach | +|---|---|---|---| +| `L_CLARIFY` | state 01 self-loop | 3 | `Blocked` | +| `L_RESEARCH` | state 02 self-loop | 2 | continue with logged `gap` | +| `L_UX_EDGE` | state 04 self-loop | 2 | `partial-coverage` flag (S06 risk tolerance permitting) | +| `L_REVISION` | 09 → 10 → upstream → 08 → 09 | 3 full cycles | `Blocked` + escalation summary | +| `L_AUDIT_FIX` | 08 ↔ 10 | 3 | escalates into `L_REVISION` accounting | + +`L_REVISION` is the primary product loop and maps onto the lifecycle's `L_DESIGN`. It terminates by user `approve` or by ceiling — never silently. + +## 9. Method Rules — the index + +The vendored [`method-rules.md`](../design-toolkit/docs/method-rules.md) is the citable catalogue; each vendored skill carries the full statement of its own set. Cite by code in S14 findings, S16 entries and gate records. + +| Prefix | Owner state | Concerns | +|---|---|---| +| `B1`–`B8` · `F1`–`F3` | 07 PROTOTYPE | build method; Figma plugin-API traps | +| `M1`–`M6` | 08 SELF_AUDIT | verification method | +| `G1`–`G8` | 09 USER_REVIEW | review + gate-record method | +| `R1`–`R8` | 10 REVISION | triage + routing method | +| `P1`–`P8` | 11 FINAL_OUTPUT | packaging + freeze method | +| `W1`–`W10` · `E1`–`E7` | 12 FLOW_VISUALIZATION | navigation mapping + extensions | + +The three that shape the rest: **a DOM-assertion suite is not a substitute for looking at the render** · **a failing probe is a hypothesis, not a finding** · **an approval is scoped to the bytes it saw**. + +## 9b. Cross-State Rules + +Four rules bind more than one state. They are the ones most often broken by a change that looked local. + +| Rule | Statement | Binds states | +|---|---|---| +| **Scope your clearance claims** | "No boundary mocks left" was written about one flow and read as holding for the set. State the scope *inside* the claim. The mirror case: a gap recorded for one flow that a mechanical scan found in three. | 05, 07, 08, 12 | +| **An unruled question is carried, never defaulted** | An unanswered guard is an open decision (`o-`), not a branch invented at build time. An invented value becomes a frozen number nobody owns. | 04, 05, 06, 07 | +| **A shared component is a cross-flow contract** | A component used by more than one flow names its owning plan, or each file re-decides it — and they drift. | 06, 07 | +| **Facts promised at a boundary are contracts** | A fact one flow promises at a `⟂` boundary belongs in **both** flows' decision logs, or two internally consistent flows will disagree. | 05, 10, 11 | + +## 9c. Trimming the Pipeline + +**Legitimately skippable:** + +- **State 12** when the work is not going to a build team — `design.handoff_required: false` (the default here). The skip is logged in S16. +- **State 02 research** for a goal explicitly marked `no-research-needed` — **per goal, not wholesale**. + +**Never skippable:** + +- **State 08 before state 09.** The audit exists so the user never debugs. +- **State 09 before state 11.** The gate exists so the machine never ships on its own authority. + +A "small feature" uses smaller *content*, never fewer *states*. + +## 10. Verification Harness + +Vendored under [`../design-toolkit/tools/`](../design-toolkit/tools/) (Node, no install). Rules that name a tool are evidenced by its exit code, not by assertion: + +| Tool | Serves | Use | +|---|---|---| +| `audit.mjs` | 08 | rendering-class audit sweep (visibility, geometry, tap targets, palette, console) | +| `smoke.mjs` · `stateprobe.mjs` · `cdp.mjs` | 07, 08 | drive every view headless; confirm destinations paint (`B6`) | +| `linkcheck.mjs` · `mermaidcheck.mjs` | 07, 08, 12 | dead links + diagram syntax | +| `navgraph.mjs` | 12 | derive the navigation graph from the registry (`W1`) | +| `stategraph.mjs` | 12 | per-screen state machines from the normalized vocabulary (`E5`) | +| `annotate.mjs` | 12 | developer annotations with cited evidence (`E6`) | +| `config.mjs` · `config.schema.json` | all | config resolution; field reference for the manifest `design:` block | + +Tools read the **generated** `toolkit.config.json` at the project root (manifest `design:` block + the BRD's S08 allowlist) — never the vendored file (see [VENDORED.md](../design-toolkit/VENDORED.md)). + +**Full catalogue — what each tool checks, its exit codes, its failure-and-fix table, the false-positive catalogue, the waiver rule and the run order: [validation-engine.md](validation-engine.md).** Three things from it that bind every state: exit `2` means the check **did not run** (unevaluable, not passing) · a failing probe is a hypothesis until confirmed at source · a failing check that has not been confirmed at source **is not eligible for a waiver**. + +## 11. Deltas from the Vendored Spec -1. Artifact store → Living BRD sections + repo prototype dir (§1). No loose `.md` files. -2. `machine_state` → Notion properties; resumable across sessions by construction. -3. `HALT_BLOCKED`/`HALT_STOPPED` → lifecycle `Blocked`/`Stopped`. -4. States 01–03 execute under lifecycle Analysis/Planning — one pass, not duplicated work; the design stage trusts their outputs and back-transitions if they prove malformed. -5. Skill-per-state → states map to toolkit Skills (UX Designer: 04–05; UI Designer: 06–07; DS Engineer: 06–07 support; orchestrator: 08–11 routing). -6. Validation rules → mirrored as Checklists/ items in Phase 4 (ux-review, ui-review, design-qa); this file stays normative. +1. **Artifact store** → Living BRD sections + repo directories (§1). No loose `artifacts/*.md`. +2. **Reference inputs** → the Screen Contract is the registry; state 12 derives from it rather than from a parallel `screen-registry.csv` (§1). +3. **`machine_state`** → Notion properties; resumable across sessions by construction. +4. **`HALT_BLOCKED`/`HALT_STOPPED`** → lifecycle `Blocked`/`Stopped`. +5. **States 01–03** execute under lifecycle Analysis/Planning — one pass, not duplicated work; the design stage trusts their outputs and back-transitions if they prove malformed. +6. **Skill-per-state → toolkit Skills:** UX Designer 04–05; UI Designer 06–07; DS Engineer supports 06–07; UI Designer + FE Engineer 12; orchestrator routes 08–12. +7. **`handoff_required` defaults to `false`** here, where the vendored spec leaves it unset. The lifecycle already carries design→dev traceability in the Screen Contract, so the navigation map is opt-in per project or per BRD (§6) — a fourth human gate is never imposed silently. +8. **`toolkit.config.json` → `project-manifest.yaml` `design:` block.** One config per project, not two. +9. **Validation rules** are mirrored as executable Checklists items ([ux-review](../Checklists/ux-review.md), [ui-review](../Checklists/ui-review.md), [design-qa](../Checklists/design-qa.md), [flow-visualization](../Checklists/flow-visualization.md)); this file plus the vendored spec stay normative. diff --git a/Architecture/ecosystem-map.md b/Architecture/ecosystem-map.md new file mode 100644 index 0000000..6850ee8 --- /dev/null +++ b/Architecture/ecosystem-map.md @@ -0,0 +1,49 @@ +# Ecosystem Map — Dev-toolkit ↔ paul ↔ gsd-core ↔ carl + +> **Module:** Architecture / Foundation +> **Status:** Informational (not executed by the orchestrator; no runtime dependency) +> **Purpose:** Concept-level bridge between this toolkit and three unrelated Claude Code systems the user also runs (`paul:*` plugin, `gsd-*` plugin family, `carl-mcp`). Answers "what's the equivalent of X" when moving between them. Does not wire them together — see §4. + +## 1. The Four Systems + +| System | What it is | State lives in | Unit of work | +|--------|-----------|-----------------|--------------| +| **Dev-toolkit** (this repo) | Versioned, submoduled process toolkit + Notion-backed state machine | Notion (Living BRD, properties) + `project-manifest.yaml` + `~/.toolkit/registry.yaml` | BRD, through 13 gated stages ([workflow-state-machine](workflow-state-machine.md)) | +| **paul** | Claude Code plugin: milestone → phase planning/execution workflow | Local files in the project repo (manifest via `paul:register`, phase/handoff docs) | Phase, driven by discuss → plan → apply → verify | +| **gsd-core** (`gsd-*`) | Claude Code plugin family: much larger phase-lifecycle system with specialized sub-agents per concern (UI, security, eval, AI integration, memory, knowledge graph) | Local `.planning/` files in the project repo (`ROADMAP.md`, `PLAN.md`, `SPEC.md`, etc.) | Phase, driven by discuss-phase → plan-phase → execute-phase → verify-work, with optional sub-phase contracts | +| **carl** (`carl-mcp`) | MCP server: persistent, cross-project rules + decision memory, auto-injected at session start via hook | Domain files outside any project repo (global, per-machine); a `` block is pushed into every session | No lifecycle unit — it's a standing rules/decision layer, not a workflow | + +Dev-toolkit and gsd-core both model a **gated feature lifecycle** with human checkpoints. paul is a lighter version of the same idea. carl is orthogonal — it never advances a feature through stages; it just remembers rules and decisions and reminds every session of them. + +## 2. Concept Mapping + +| Dev-toolkit concept | paul equivalent | gsd-core equivalent | carl equivalent | +|---|---|---|---| +| Living BRD (Notion page, S01–S16) | phase doc produced by `paul:discuss` / `paul:plan` | `SPEC.md` / `PLAN.md` per phase (`gsd-spec-phase`, `gsd-plan-phase`) | — (no per-feature document) | +| `project-manifest.yaml` ([project-manifest](project-manifest.md)) | `paul.toml` (`paul:register`) | `PROJECT.md` + `.planning/` config | `carl_get_manifest` — global, not per-project | +| Toolkit Registry (`~/.toolkit/registry.yaml`, [toolkit-registry](toolkit-registry.md)) | — (no user-global registry found) | global config via `gsd-config` / `gsd-settings` (model profile, workflow toggles) | the `always_on` global rule domain — loaded into every session regardless of project | +| S16 Decision Log | `paul:handoff` session handoff | `gsd-extract-learnings`; decisions recorded in `PLAN.md`/`ROADMAP.md` | `carl_log_decision` → per-domain decision log (`carl_get_decisions`, `carl_search_decisions`) | +| Standards/ (17 tech-rule files, always loaded by relevance) | — (no standards layer found) | implicit in specialized auditors (`gsd-security-auditor`, `gsd-code-reviewer`) rather than a standalone rules set | domain rules (`carl_get_domain_rules`), keyword-triggered per session — e.g. this repo's `BILLING` / `DESIGN` / `ONBOARDING` domains | +| Workflow Orchestrator ([orchestrator](../AI/orchestrator.md)) | `paul:progress` (routes to next action) | `gsd-next` / `gsd-progress` (routes to next action) | none — carl never routes, only injects context | +| 13-stage BRD state machine | discuss → plan → apply → verify, milestone/phase CRUD (`paul:milestone`, `paul:add-phase`) | discuss-phase → plan-phase → execute-phase → verify-work, plus optional `spec-phase`, `ui-phase`, `ai-integration-phase`, `secure-phase`, `eval-review` contracts | — | +| Screen Contract ([screen-contract](screen-contract.md), `C_CONTRACT`) | — (no design-traceability concept found) | `UI-SPEC.md` (`gsd-ui-phase`, `gsd-ui-checker`, `gsd-ui-review`) | — | +| Skills/ (16 roles, [permission-matrix](permission-matrix.md)) | — (no per-role permission model found) | specialized sub-agents per concern (`gsd-planner`, `gsd-executor`, `gsd-verifier`, `gsd-security-auditor`, …) — closest analog, but agent-scoped, not rights-matrix-scoped | — | +| Session resume (Notion state only, no session memory) | `paul:pause` / `paul:resume` (handoff file) | `gsd-pause-work` / `gsd-resume-work`, `gsd-thread` | session-start rule re-injection (`` block) — re-establishes rule context every session, not a work-resume mechanism | +| Codebase mapping at onboarding ([project-onboarding](../Workflows/project-onboarding.md)) | `paul:map-codebase` | `gsd-map-codebase`, `gsd-onboard`, `gsd-ingest-docs` | — | +| Cross-project reuse unit | none observed — paul is per-repo, no pinned shared dependency | none observed — same | domains are global and live across every project on the machine, unversioned | + +## 3. What's Genuinely Different (not just naming) + +- **Where state lives.** Dev-toolkit externalizes all feature state to Notion — human-readable, human-editable, survives outside any repo. paul and gsd-core keep state as files inside the project repo. carl keeps state outside every repo, machine-global. +- **Gate mechanics.** Dev-toolkit gates are typed tokens in Notion properties (`Approvals` contains `direction`/`design`/`final`), checked programmatically, invalidated on stale content (§6 of [workflow-state-machine](workflow-state-machine.md)). paul/gsd-core gates are conversational checkpoints (`AskUserQuestion`, review prompts) — no persisted approval token to go stale. +- **Distribution model.** Dev-toolkit is a semver-tagged git submodule — a project pins a version and upgrades deliberately ([versioning](versioning.md)). paul and gsd-core are Claude Code plugins installed at the tool level, not pinned per-project; upgrading the plugin upgrades every project at once. +- **carl has no lifecycle.** It doesn't compete with the other three's phase machines — it's a standing rules-injection + decision-log layer that runs underneath whatever workflow is active. It's closer in spirit to Dev-toolkit's Standards/ + S16 Decision Log combined, but scoped globally instead of per-toolkit-version and per-BRD. + +## 4. Boundary (why this is a map, not a wire) + +These four systems are **not connected**. Running paul or gsd-core commands inside a Dev-toolkit-managed project would create a second, competing source of truth (local `.planning/`/`.paul/` files vs. the Notion BRD) — the Project Boundary Rule ([integration-map](integration-map.md) §2b) and `C_MANIFEST` guard don't know about either. This document is descriptive only: it exists so a session or a human can translate a request between vocabularies ("do the paul equivalent of a Direction Gate") without guessing. It creates no dependency, changes no gate, and nothing here is read by [orchestrator](../AI/orchestrator.md). + +If real integration is ever wanted, the two lowest-risk seams are: +- **carl → S16**: mirror BRD Decision Log entries into a carl domain via `carl_log_decision`, since carl's decision log is additive and doesn't own a lifecycle it could conflict with. +- **gsd-core sub-agents as optional executors** inside a single Dev-toolkit stage (e.g. `gsd-security-auditor` invoked *from* the Tech Review stage) — additive, doesn't touch the state machine. +Both are deliberately out of scope here. diff --git a/Architecture/integration-map.md b/Architecture/integration-map.md index fe7e5c7..eafae47 100644 --- a/Architecture/integration-map.md +++ b/Architecture/integration-map.md @@ -10,14 +10,15 @@ |--------|-------|-------------| | **Notion** (one DB, all projects) | All feature knowledge: Living BRDs (S01–S16), workflow state (properties), approvals, decision history | Code, build artifacts, global standards | | **Toolkit repo** (this repo, submoduled) | All global knowledge: workflows, skills, standards, templates, checklists, prompts | Project business logic, feature knowledge, secrets | -| **Project repo** | Code, prototypes, tests, thin `CLAUDE.md`, project overrides, toolkit submodule pin | Copies of toolkit content, feature knowledge that belongs in the BRD | +| **Toolkit Registry** (`~/.toolkit/registry.yaml`, per user/machine — [toolkit-registry](toolkit-registry.md)) | User-global config: BRD DB identity, projects parent page, bot presence | Secrets, project configuration, feature knowledge | +| **Project repo** | Code, prototypes, tests, thin `CLAUDE.md`, project overrides, toolkit submodule pin | Copies of toolkit content, feature knowledge that belongs in the BRD, user-global facts (inherited from the registry) | **Knowledge separation rule (hard):** feature knowledge → Notion BRD. Global process knowledge → toolkit. Project-specific technical knowledge (stack quirks, env setup, project conventions that override standards) → project repo `CLAUDE.md` + `project-overrides.md`. Anything written in the wrong layer gets moved, not duplicated. **Three sources of truth (v1.2):** | Artifact | Truth for | Lives in | |----------|-----------|----------| -| [`project-manifest.yaml`](project-manifest.md) | project configuration | project repo root | +| [`project-manifest.yaml`](project-manifest.md) | project configuration + **resource registry** | project repo root | | Living BRD | feature requirements + decisions | Notion | | [Screen Contract](screen-contract.md) (`screens/`) | design→development traceability | project repo | @@ -47,6 +48,25 @@ Workflows consume these; they never re-collect or duplicate their content. Confi └───────────────────────────────────────────┘ ``` +## 2b. Project Boundary Rule (v1.5 — hard) + +Every project explicitly **owns and binds** its external resources at onboarding ([Project Resource Binding](../Workflows/project-onboarding.md) step 3). The Project Resource Registry — the manifest's `resources:` block plus the `communication:` section ([project-manifest](project-manifest.md) §3) — is the complete, closed list: Notion databases/pages, Figma files/libraries, GitHub repositories, documentation targets, communication channels, each stored by **stable identifier** (database id, file key, repo id, chat id — never display names). + +After onboarding, the Workflow Orchestrator and every workflow operate **only inside that registry**. Forbidden, always: + +- browsing or searching the user's full Notion workspace (`notion-search` beyond registered databases/pages) +- browsing the user's Figma teams/projects or opening unregistered files +- listing or reaching into GitHub repositories other than the bound ones +- reading documents, chats, or MCP resources not registered to the project + +A stage that needs a resource the registry doesn't hold — or holds skipped or `health: unreachable` — does exactly this, nothing else: **stop**, set the BRD `Blocked (resource: )` (resumable; fires the Telegram failure trigger), and raise a **Resource Decision** ([project-manifest](project-manifest.md) §3): + +1. **connect an existing resource**, or +2. **create a new one** (either = targeted Resource Binding re-run for that slot), or +3. **confirm absence** — the slot stays/becomes `skipped` and its absence behavior applies. + +**Never guess, never workspace-search, never substitute a look-alike.** Boundary violations are process defects; report them like any other broken rule. Enforcement lives in the orchestrator's Manifest Gate + anti-rules ([../AI/orchestrator.md](../AI/orchestrator.md)). + ## 3. Naming Contracts | Thing | Format | Example | @@ -60,6 +80,8 @@ Workflows consume these; they never re-collect or duplicate their content. Confi Bidirectional links: BRD `Branch` + `PR` properties point at Git; PR body + commits point at BRD. Either side reachable from the other in one hop. +**Multi-repo BRDs** (backend/infra repos bound beyond the primary): the same branch name is used in every affected repo; one PR per affected repo; the BRD `PR` property holds the primary repo's PR, whose body links the sibling PRs (and S16 lists them). The Final Gate reviews **all** PRs of the set — merge is atomic in intent: none merge until the gate approves the set. + ## 4. Lifecycle Loop (end to end) 1. **Intake** — you create/refine a BRD page, set `Project`, `Priority`, tick `Ready`. @@ -74,17 +96,17 @@ Bidirectional links: BRD `Branch` + `PR` properties point at Git; PR body + comm ## 5. Project Onboarding (new project, ~10 min) 1. `git submodule add toolkit && cd toolkit && git checkout ` -2. Copy `toolkit/AI/CLAUDE-global.md` reference block into project `CLAUDE.md` (points at submodule; adds project code, stack notes, overrides file path). -3. Create `project-overrides.md` (empty scaffold) — the only place project rules may differ from Standards/. -4. In Notion: add project to the DB `Project` select. Done — BRDs immediately pickable. +2. Run [../Workflows/project-onboarding.md](../Workflows/project-onboarding.md): register → collect/detect stack → **bind every resource** (Connect Existing / Create New / Skip per slot; stable IDs into the registry) → validate integrations + bindings → generate manifest → seed Screen Contract + context/. BRDs pickable only after `onboarding.status: complete` + `resources.status: bound`. +3. Scaffolds land automatically: project `CLAUDE.md` block (from `toolkit/AI/CLAUDE-global.md`), `project-overrides.md` — the only place project rules may differ from Standards/. +4. From here the Project Boundary Rule (§2b) holds: sessions touch registry resources only. ## 6. MCP / Tool Requirements per Layer -| Integration | Tool | Used by | -|-------------|------|---------| -| Notion | Notion MCP (`notion-fetch`, `notion-update-page`, `notion-search`, `notion-query-data-sources`) | orchestrator, every workflow | -| GitHub | `gh` CLI | Workflows/git, release, code-review | -| Figma | Figma MCP | ui-workflow, design-system-workflow (when Figma refs exist) | -| Local | Bash, file tools | implementation, qa, debug | +| Integration | Tool | Used by | Boundary scope (§2b) | +|-------------|------|---------|----------------------| +| Notion | Notion MCP (`notion-fetch`, `notion-update-page`, `notion-query-data-sources`) | orchestrator, every workflow | registered databases/pages only; no workspace search | +| GitHub | `gh` CLI | Workflows/git, release, code-review | bound repositories only | +| Figma | Figma MCP | ui-workflow, design-system-workflow, flow-visualization (when Figma bound) | bound file keys only; no team browsing | +| Local | Bash, file tools | implementation, qa, debug | project repo(s) | Details in [../AI/mcp-setup.md](../AI/mcp-setup.md). diff --git a/Architecture/permission-matrix.md b/Architecture/permission-matrix.md index ac4d82a..5aeb88a 100644 --- a/Architecture/permission-matrix.md +++ b/Architecture/permission-matrix.md @@ -11,6 +11,7 @@ - **A** — append: may add new entries; may not modify existing content - **R** — read only - All roles have **A** on S16 (Decision Log). S16 is append-only for everyone, always. +- **S14 append rights** cover audit findings and gate records — new entries produced by a self-audit or a gate, never edits to another role's review content. UI Designer holds it for the design-audit subsection (design state 08) and the Developer Handoff Gate record (design state 12). ## 2. Matrix @@ -19,7 +20,7 @@ | Business Analyst | E | E | E | E | E | E | R | R | R | R | R | R | R | R | R | A | | Product Manager | A | E | A | A | A | A | R | R | R | R | R | R | R | R | R | A | | UX Designer | R | R | R | R | A | R | E | A | E | R | R | R | R | R | R | A | -| UI Designer | R | R | R | R | A | R | A | E | A | R | R | R | R | R | R | A | +| UI Designer | R | R | R | R | A | R | A | E | A | R | R | R | R | A | R | A | | Design System Engineer | R | R | R | R | A | R | R | E | R | R | A | R | R | R | R | A | | Frontend Engineer | R | R | R | A | A | R | R | R | R | E | E | E | R | R | R | A | | Backend Engineer | R | R | R | A | A | R | R | R | R | E | E | E | R | R | R | A | diff --git a/Architecture/project-manifest.md b/Architecture/project-manifest.md index 1fb1536..3b2b2b7 100644 --- a/Architecture/project-manifest.md +++ b/Architecture/project-manifest.md @@ -1,22 +1,23 @@ # Project Manifest -> **Module:** Architecture / Foundation (v1.2) +> **Module:** Architecture / Foundation (v1.5) > **Status:** Stable -> **Purpose:** Single source of truth for **project configuration**. Generated by [../Workflows/project-onboarding.md](../Workflows/project-onboarding.md); consumed by every workflow module. Workflows read the manifest — they never re-ask the user for project information it contains. -> **Rule of three sources:** Manifest = project config · Living BRD = feature requirements · Screen Contract = design-to-development traceability. Nothing duplicated across them; changes update the owning source. +> **Purpose:** Single source of truth for **project configuration** and the **Project Resource Registry**. Generated by [../Workflows/project-onboarding.md](../Workflows/project-onboarding.md); consumed by every workflow module. Workflows read the manifest — they never re-ask the user for project information it contains, and they never touch an external resource it doesn't register (§3 Project Boundary Rule). +> **Rule of three sources:** Manifest = project config + resource registry · Living BRD = feature requirements · Screen Contract = design-to-development traceability. Nothing duplicated across them; changes update the owning source. ## 1. Location & Lifecycle -- File: `project-manifest.yaml` at project repo root. Versioned with the code. -- Created once by onboarding; **no BRD, workflow, or skill executes for a project without `onboarding.status: complete`** (orchestrator guard `C_MANIFEST`, [workflow-state-machine.md](workflow-state-machine.md)). -- Updates: project evolves (new stack element, Figma file added, DB moved) → re-run the affected onboarding section; manifest edited + `last_validated` stamped + change noted in the next BRD's S16 or a dedicated maintenance note. Manual hand-edits allowed but must pass integration re-validation before the next BRD pickup. -- Staleness: `last_validated` older than 30 days → orchestrator re-runs [integration-validation](../Workflows/integration-validation.md) at next pickup (cheap re-check, not full onboarding). +- File: `project-manifest.yaml` at project repo root (the `git.primary_repository` repo). Versioned with the code. +- Created once by onboarding; **no BRD, workflow, or skill executes for a project without `onboarding.status: complete`** (orchestrator guard `C_MANIFEST`, [workflow-state-machine.md](workflow-state-machine.md)) — which since v1.5 includes `resources.status: bound`. +- Updates: project evolves (new stack element, new resource, DB moved) → re-run the affected onboarding section; manifest edited + `last_validated` stamped + change noted in the next BRD's S16 or a dedicated maintenance note. Manual hand-edits allowed but must pass integration re-validation before the next BRD pickup. +- Staleness: `last_validated` older than 30 days → the Manifest Gate re-runs [integration-validation](../Workflows/integration-validation.md) before project work (cheap re-check, not full onboarding). +- **Migration (manifest_version 1 → 2):** owned by the **Manifest Gate** ([../AI/orchestrator.md](../AI/orchestrator.md) responsibility 0) — it detects `manifest_version: 1` at session entry (pickup and resume alike, **before** any staleness check or validation, which need registry ids a v1 manifest lacks) and runs the Migration procedure ([../Workflows/project-onboarding.md](../Workflows/project-onboarding.md) §Migration): automatic offer, v1 fields seed the bindings, only genuinely new slots are asked, resumable and idempotent. `manifest_version: 2` is stamped only when the migration checklist passes in full. In-flight BRDs are untouched — machine state lives in Notion; they resume normally the moment migration completes. A v1 manifest is **not** pickable except through this path. ## 2. Schema (normative) ```yaml -manifest_version: 1 # manifest schema version (this file's contract) -toolkit_version: v1.2.0 # toolkit the project is pinned to +manifest_version: 2 # v1.5 — Project Resource Binding (resources: registry) +toolkit_version: v1.6.0 # toolkit the project is pinned to onboarding: status: complete # incomplete | complete — the master gate completed: 2026-07-26 @@ -38,33 +39,84 @@ stack: package_manager: npm # pnpm | npm | yarn | bun profile: nextjs-tailwind # resolved via Architecture/stack-profiles.md → which Standards apply -design: - figma_file: null # URL or null (null = prototype-only design flow) - figma_pages: [] - design_system_file: null # Figma library URL or null +design: # code-side design config ONLY — Figma resource identity lives in resources.figma component_library: shadcn/ui # code-side component base token_source: src/styles/globals.css # where @theme / tokens live icon_library: lucide-react typography_source: next/font - validated: true # all non-null resources fetch-checked at onboarding - -notion: - workspace: - brd_database: # required - brd_data_source: - project_database: null # optional registry - sprint_database: null # optional - decision_log_database: null # optional — default is in-BRD S16 - -git: - repository: # required - default_branch: main + +resources: # v1.5 — PROJECT RESOURCE REGISTRY (§3) — sole home of external + # resource identity (plus communication: below, at its own path) + status: bound # unbound | bound — every slot below explicitly resolved + bound: 2026-07-26 # date the registry was completed + + notion: + workspace: + brd_database: # REQUIRED — inherited from the Toolkit Registry + id: <32-hex database id> # ([toolkit-registry](toolkit-registry.md)); confirmed, never re-asked + data_source: + url: # convenience pointer; id is authoritative + binding: connected # connected | created + bound: 2026-07-26 + validated: 2026-07-26 # last access check + project_page: # optional — project identity page (v1.3 registration target) + id: <32-hex page id> + binding: created + bound: 2026-07-26 + validated: 2026-07-26 + sprint_database: null # optional — null with binding: skipped recorded below when declined + decision_log_database: null # optional — default is in-BRD S16 + skipped: [sprint_database, decision_log_database] # explicit declines — never re-asked, never guessed + + figma: + product_design_file: # optional — absent/skipped = prototype-only design flow + key: # stable key from figma.com/design//… — never the file name + pages: [] + binding: connected + bound: 2026-07-26 + validated: 2026-07-26 + design_system_library: null # Figma library; same shape when bound + skipped: [design_system_library] + + github: + frontend_repository: # ≥1 repository REQUIRED across the three slots + id: # gh api repos/{owner}/{repo} --jq .id — survives renames/transfers + url: + default_branch: main + binding: connected + bound: 2026-07-26 + validated: 2026-07-26 + backend_repository: null # monorepo → bind the same repo (same id) to both roles + infrastructure_repository: null # optional + skipped: [backend_repository, infrastructure_repository] + + documentation: # optional slots; each type: notion-page | repo-path | url + api_documentation: + type: repo-path + identifier: docs/api/ # notion-page → page id · repo-path → : · url → URL + binding: created + bound: 2026-07-26 + validated: 2026-07-26 + architecture_documentation: null + product_documentation: null + skipped: [architecture_documentation, product_documentation] + + other: [] # additional MCP-backed resources, same binding record shape + # - name: analytics-dashboard + # mcp: + # identifier: + # binding: connected + # bound: 2026-07-26 + # validated: 2026-07-26 + +git: # behavior config — repository IDENTITY lives in resources.github + primary_repository: frontend # which resources.github slot hosts manifest, toolkit pin, screens/ branch_convention: feat/- # toolkit default unless overridden pr_strategy: squash # squash | merge | rebase release_strategy: none # tag | vercel | manual-deploy | none -integrations: # written by integration-validation, per run - notion_mcp: validated # validated | unavailable | error: +integrations: # written by integration-validation, per run — TOOL availability, + notion_mcp: validated # not resource identity. validated | unavailable | error: github_cli: validated figma_mcp: unavailable browser_automation: unavailable # browser/playwright MCP @@ -74,40 +126,149 @@ screen_contract: path: screens/ # registry + per-screen contracts live here next_id: 1 # next SCR number to allocate +security: # v1.8 — evidence commands for C_SECURITY (security-certification). + # A command left empty is a recorded GAP, never a silent pass. + scanners: + secrets: "" # e.g. "gitleaks detect --no-banner --redact" + dependencies: "" # e.g. "npm audit --audit-level=high" + sast: "" # e.g. "semgrep --config auto --error" + licenses: "" + fail_on: high # severity floor that blocks certification: critical | high | moderate + high_risk_scopes: # a delta touching these forces a FULL re-certification, never delta-only + - auth + - payment + - pii + - data-export + rotate_on_secret_hit: true # a secret in a pushed commit is published — deletion is not rotation + +design: # v1.7 — design sub-machine config (field reference: + # design-toolkit/toolkit.config.json, vendored) + handoff_required: false # C_HANDOFF_REQUIRED — true runs design state 12 + # (navigation map + Developer Handoff Gate) on the + # Design Review → Dev Planning edge. Per-BRD override: + # the BRD's "Handoff Required" property. + design_system: + name: "" # DS name + source_id: "" # library/repo + version — the id UI planning validates + # against. A plan built on the wrong DS validates perfectly. + viewport: { width: 393, height: 852 } # prototype review/capture viewport + scripts: [] # writing systems the product renders, e.g. + # [{ name: Khmer, range: "1780-17FF", fontMatch: Khmer }] + # — self-audit proves each resolves on the BASE font stack + review_port: 8765 # run-local.sh default port + navmap_path: design/navmap/ # derived navigation artifacts (state 12) + prototype_path: design/prototype/ # per-BRD prototypes + loops: { L_CLARIFY: 3, L_RESEARCH: 2, L_UX_EDGE: 2, L_REVISION: 3, L_AUDIT_FIX: 3 } + context_package: # v1.3 — generated AI context summaries path: context/ generated: 2026-07-26 # regenerate when older than onboarding.last_validated -communication: # v1.4 — optional integrations; asked ONCE at onboarding - telegram: +communication: # COMMUNICATION section of the resource registry (§3). Physical + telegram: # path kept stable — the plugin's YAML reader depends on it enabled: false # false = never ask again (re-open only via # "toolkit configure communication" / "toolkit onboard --update") + # deferred: true # set when the answer was "configure later" (vs "no") — + # the onboarding report prints the re-open phrase once # when enabled: # mode: topic # private | group | topic (recommended) - # chat_id: "-1001234567890" + # chat_id: "-1001234567890" # stable chat identifier # topic_id: 42 # topic mode only + # binding: connected # connected (existing bot/chat) | created (new chat/topic) + # bound: 2026-07-26 + # validated: 2026-07-26 # last test-send # notifications: # approvals: true # direction/design gates + PR-ready # failures: true # pipeline failed - # pipeline: true # daily_summary: false # v2 — declared, not implemented + # exec_progress: true # relay per-step progress of a remote run + # exec: # remote execution — OFF unless every field below is set + # enabled: false # master switch; false = free-text messages are refused + # allowed_user_ids: [111] # Telegram user ids permitted to run commands; empty = nobody + # permission_mode: acceptEdits # acceptEdits (default) | plan | dontAsk | bypassPermissions + # allowed_tools: ["Read", "Edit", "Bash(git *)"] # omit to allow the mode's defaults + # timeout_minutes: 10 # run is SIGTERMed past this # slack / discord / email: future — same shape, sibling adapters ``` +**Remote execution rule (v1.6):** `communication.telegram.exec` turns a chat message into a `claude -p` run on the machine hosting the executor. It is default-closed on two independent switches — `exec.enabled` and a non-empty `exec.allowed_user_ids` — and both must be set deliberately. `permission_mode: bypassPermissions` grants unrestricted tool and shell access to anyone who can post in the bound chat; treat enabling it as equivalent to handing that chat a terminal on the host. Run the executor only on a machine you control, and only for a chat whose membership you control ([extensions/telegram/README.md](../extensions/telegram/README.md)). + **Secrets rule:** bot tokens NEVER enter the manifest or repo — `TELEGRAM_BOT_TOKEN` env var only ([extensions/telegram/README.md](../extensions/telegram/README.md); [Standards/security.md](../Standards/security.md) rule 5). Manifest holds routing (chat/topic ids) — non-secret. -## 3. Consumption Rules +## 3. Project Resource Registry & Boundary (v1.5) + +The **Project Resource Registry** = the `resources:` block **plus** the `communication:` section (kept at its own top-level path — the Telegram plugin's YAML reader depends on it, v1.4 contract). Together they are the complete, closed list of external resources this project owns. Established by the onboarding **Project Resource Binding** stage ([../Workflows/project-onboarding.md](../Workflows/project-onboarding.md) step 3); every supported slot is resolved there as **Connect Existing** / **Create New** / **Skip**. + +**Binding record (uniform):** + +| Field | Meaning | +|-------|---------| +| `id` / `key` / `identifier` | **Stable identifier** — the only authoritative reference | +| `url` | Convenience pointer for humans; never used for resolution | +| `binding` | `connected` (pre-existing, linked) · `created` (made by onboarding) · `skipped` (explicitly declined; recorded in the section's `skipped:` list) | +| `bound` | Date the binding decision was made | +| `validated` | Date of last successful access check | +| `health` | `ok` (default, omitted) · `unreachable: ` — written by [integration-validation](../Workflows/integration-validation.md) when the stable id stops resolving. Unreachable ≠ unbound: the decision record stays; only a Resource Decision changes it | + +**Resource lifecycle — disposition vs health.** `binding` records *the decision* (immutable except through a Resource Decision); `health` records *runtime reality* (restamped on every validation). The two never conflate: a deleted Figma file is `binding: connected, health: unreachable`, not silently unbound. + +**Resource Decision (the one runtime primitive).** When a stage concretely requires a slot that is (a) skipped, (b) unbound with no free slot, or (c) bound but unreachable, the orchestrator raises exactly one decision — never guesses, never searches: + +> **connect existing** / **create new** / **confirm absence** + +- *connect/create* → targeted Resource Binding re-run for that slot (Re-Onboarding rule); work resumes. +- *confirm absence* → slot is (or becomes) `skipped` — an unbind, recorded in the `skipped:` list + S16 — and the slot's **absence behavior** (below) applies; if that behavior is `blocks`, the BRD stays `Blocked (resource: )`. +- While the decision is pending, the BRD is `Blocked`, reason `resource: ` — resumable, surfaced at session entry, and the Telegram failure trigger fires ([../AI/orchestrator.md](../AI/orchestrator.md) 0b). +- This scopes the skip promise precisely: **skipped slots are never re-asked proactively**; only a stage that cannot proceed without the slot may raise one Resource Decision, and a confirmed absence is honored again until the next such stage. + +**Absence behavior per optional slot** (what "proceed without it" means — the generalization of the Figma rule): + +| Slot | Absent / skipped / confirmed-absent behavior | +|------|----------------------------------------------| +| `figma.product_design_file` | prototype-only design flow ([screen-contract](screen-contract.md) §4) | +| `figma.design_system_library` | DS truth = code tokens + component library only | +| `notion.project_page` / `sprint_database` / `decision_log_database` | feature off (S16-in-BRD remains the decision log) | +| `documentation.*` | doc-sync duties skip that target; technical-writer logs the gap once | +| `github.backend_repository` / `infrastructure_repository` | **blocks** any stage whose plan requires it (Resource Decision fires at Dev Planning exit — guard `C_RESOURCES`) | +| `communication.telegram` | no notifications; terminal gates only | + +**Rebind fallout (hard):** any registry mutation on a slot that existing Screen Contract mappings or granted approvals reference → the affected mappings are marked invalid, the owning screens' status regresses (logged), and the stale-approval rule fires on the owning BRDs ([screen-contract](screen-contract.md) §5). Replacing a design file is a design change, never a silent swap. + +**Rebind logging & persistence:** mid-BRD rebind → that BRD's S16; rebind outside any BRD → the next pickup's S16. The manifest edit commits to the primary repo's default branch as `chore: rebind `; in-flight sessions re-read the manifest at next stage entry. + +**Stable identifiers only — never display names:** + +| Provider | Store | Never store | +|----------|-------|-------------| +| Notion | database/page ID (32-hex) + `collection://` data source | page titles, DB names | +| Figma | file key (`figma.com/design//…`) | file names | +| GitHub | numeric repo id (`gh api repos/{owner}/{repo} --jq .id`) + canonical URL | repo display names alone | +| Telegram | `chat_id` (+ `topic_id`) | chat titles | +| Documentation | page id / repo path / URL per `type` | document titles | + +Names get renamed; IDs don't. A binding that breaks on rename is a defect. + +**Required vs optional slots:** required — `resources.notion.brd_database` (inherited from the [Toolkit Registry](toolkit-registry.md)), ≥1 `resources.github` repository. Everything else optional, but **every slot is explicitly resolved**: bound or `skipped`, never silently absent, never guessed at runtime. Re-open via "toolkit onboard --update" — or a Resource Decision when a stage concretely requires the slot (above). + +**Registry status:** `resources.status: bound` only when all required slots are `connected`/`created` **and** validated, and every optional slot has an explicit disposition. For the communication section, `enabled: true | false` **is** the disposition — set once at onboarding, never silently absent. `C_MANIFEST` fails otherwise. + +**Project Boundary Rule (hard):** after onboarding, the Workflow Orchestrator and every workflow may access **only** resources in this registry. Concretely forbidden: workspace-wide `notion-search`, browsing Figma projects/teams, listing all GitHub repositories, reading documents not registered here. A stage that needs a resource the registry doesn't hold (or holds unhealthily) **stops** and raises a Resource Decision (above) — *connect existing / create new / confirm absence* — with the BRD `Blocked (resource: )` until resolved, then continues. **Never guess, never search the workspace, never substitute a look-alike.** Full rule: [integration-map.md](integration-map.md) §2b; enforcement: [../AI/orchestrator.md](../AI/orchestrator.md). + +## 4. Consumption Rules 1. Workflows resolve project facts from the manifest first — asking the user for a manifest-held value is a process bug. -2. `stack.profile` decides which Standards/ apply ([stack-profiles.md](stack-profiles.md)); stages load only the applicable set. -3. `design.figma_file: null` switches design workflows to prototype-only mode (screen-contract design mapping adapts — see [screen-contract.md](screen-contract.md) §4). -4. `integrations.*: unavailable` triggers the degradation rules of [../AI/mcp-setup.md](../AI/mcp-setup.md) — loudly, per stage. -5. `project.stage` tunes rigor defaults: `idea/discovery` may run [design-only playbook](../Playbooks/design-only.md); `production/maintenance` raises review dimensions to full always. -6. Manifest vs `project-overrides.md`: manifest = **facts** (what the project is/uses); overrides = **rule deviations** (how standards bend). Never mix. +2. Workflows resolve external resources from the registry **only** (§3 Project Boundary Rule) — reaching outside it is a boundary violation, reported as such. +3. `stack.profile` decides which Standards/ apply ([stack-profiles.md](stack-profiles.md)); stages load only the applicable set. +4. `resources.figma.product_design_file` absent/skipped switches design workflows to prototype-only mode (screen-contract design mapping adapts — see [screen-contract.md](screen-contract.md) §4). +5. `integrations.*: unavailable` triggers the degradation rules of [../AI/mcp-setup.md](../AI/mcp-setup.md) — loudly, per stage — **for bound slots only**. A deliberately skipped slot records `integrations.: n/a (skipped)` and degrades nothing: skip is a chosen mode, not a failure. `integrations` = tool availability; `resources` = what the tools may touch. Both must hold. +6. `project.stage` tunes rigor defaults: `idea/discovery` may run [design-only playbook](../Playbooks/design-only.md); `production/maintenance` raises review dimensions to full always. +7. Manifest vs `project-overrides.md`: manifest = **facts** (what the project is/uses/owns); overrides = **rule deviations** (how standards bend). Never mix. -## 4. Validation (manifest itself) +## 5. Validation (manifest itself) -- Required non-null: `project.{name,code,product_type,stage}`, `stack.package_manager` + at least one of frontend/backend, `notion.brd_database`, `git.repository`, `screen_contract.path`. +- Required non-null: `project.{name,code,product_type,stage}`, `stack.package_manager` + at least one of frontend/backend, `resources.notion.brd_database.id`, at least one `resources.github.*_repository.id`, `git.primary_repository` (must name a bound github slot), `screen_contract.path`. +- `resources.status: bound` per §3 — all required slots validated, all optional slots explicitly resolved. +- Every bound resource carries a stable identifier of its provider's type (§3 table) — a binding holding only a display name or only a URL where an id is available is invalid. - `project.code` unique across the user's projects (checked against Notion Project select options at onboarding). - `stack.profile` must resolve in stack-profiles. - Any `other:` stack entry → warning: standards coverage gap, noted in manifest and at every Dev Planning until a profile exists. diff --git a/Architecture/screen-contract.md b/Architecture/screen-contract.md index 5f15cd5..1ff24f6 100644 --- a/Architecture/screen-contract.md +++ b/Architecture/screen-contract.md @@ -1,6 +1,6 @@ # Screen Contract -> **Module:** Architecture / Foundation (v1.2) +> **Module:** Architecture / Foundation (v1.5) > **Status:** Stable > **Purpose:** Single source of truth for **design-to-development traceability**. Canonical mapping per screen: BRD requirements ↔ user flows ↔ Figma ↔ prototype ↔ frontend ↔ API ↔ QA. **Implementation may not begin while the contract is incomplete** — enforced by guard `C_CONTRACT` at Dev Planning entry ([workflow-state-machine.md](workflow-state-machine.md); validator: [../Checklists/screen-contract.md](../Checklists/screen-contract.md)). @@ -28,7 +28,7 @@ BRD sections reference screens by ID only (S07 flows name them, S08/S11/S13 cite | Owner | current stage owner role | Registry rows are created two ways (v1.3): -- **At onboarding** — known top-level screens seeded with real SCR-IDs, status `planned`, owner `unassigned`, parent flow empty. IDs exist before design/development begin. +- **At onboarding** — known top-level screens seeded with real SCR-IDs, owner `unassigned`, parent flow empty. Greenfield screens seed as `planned`; screens that already exist in a shipped app seed as **`implemented (pre-toolkit)`** — an honest state, not a false `planned`. When a BRD first claims a pre-toolkit screen, `C_CONTRACT` requires mappings only for the parts that BRD touches (backfill, not retro-design). - **At UI planning** — flow-implied screens registered per BRD (UX names them, UI planning registers them). UI planning **claims** seeded rows when a BRD's flows cover them (owner ← BRD-ID, parent flow filled) rather than creating duplicates — one screen, one ID, forever. One screen serving multiple flows lists all parent flows. `C_CONTRACT` validation only evaluates rows owned by the BRD under validation; `unassigned` rows are inert until claimed. @@ -39,7 +39,7 @@ Five mapping blocks, filled by the stage that owns each ([../Templates/design-ma | Block | Filled at | Owner | Content | |-------|-----------|-------|---------| -| **Design** | UI planning / prototype | UI Designer | Figma frame + component refs (when manifest has Figma) · DS components + tokens used · states designed | +| **Design** | UI planning / prototype | UI Designer | Figma frame + component refs (when manifest has Figma) · **Reading order · Chrome / presentation · Alignment** per bound frame (§4a) · DS components + tokens used · states designed · deviations from the frame as findings with the ruling that allowed them | | **Prototype** | Prototype | UI Designer | prototype route, interactive behaviors wired, recovery routes reachable | | **Frontend** | Dev Planning | FE Engineer | frontend route, page component file, shared components, layout, state mgmt refs (S10/S11 pointers) | | **API** | Dev Planning | BE/FS Engineer | required APIs (S11 spec names), request/response models, error states → screen states | @@ -47,17 +47,38 @@ Five mapping blocks, filled by the stage that owns each ([../Templates/design-ma Screens with no API dependencies state `api: none` explicitly — absence is declared, never implied. +**The registry is the spine.** Design state 12 derives the **entire** navigation model from these cells ([validation-engine §5](validation-engine.md)). Two consequences: if the diagram and the derivation disagree, **the diagram is wrong**; and a cell carrying prose where an id belongs is a finding (`N10-unparsed`), not a stylistic quibble — it silently drops an edge. Two separators, **not interchangeable**: `states` is **comma**-separated; `entry_from` and `navigates_to` are **pipe**-separated. + +**Navigation fields (v1.7, additive).** The Prototype block may carry three extra lines — `entry_from:`, `navigates_to:` (target `SCR-id` + condition per route), `states:` (from the project's normalized state vocabulary). They are **optional and do not affect `C_CONTRACT`**: a screen without them is a complete contract. They exist because design state 12 ([flow-visualization](../Workflows/flow-visualization.md)) derives the navigation map from this registry, and a missing route field is *reported as a finding* against the map, never guessed. Projects with `design.handoff_required: true` fill them at prototype time; everyone else can ignore them. + ## 4. Figma-Optional Rule -`manifest.design.figma_file: null` → Design block maps to **prototype + DS components only**; Figma fields marked `n/a (no figma in manifest)`. Contract validation adapts: the "exists in Figma" check becomes "exists in served prototype". When Figma exists, both prototype and Figma mappings are required and must agree — divergence is a design-qa finding. +**No healthy Figma binding** — `manifest.resources.figma.product_design_file` skipped, null, or `health: unreachable` ([project-manifest](project-manifest.md) §3) — → Design block maps to **prototype + DS components only**; Figma fields marked `n/a (no figma in manifest)`. Contract validation adapts: the "exists in Figma" check becomes "exists in served prototype". When a healthy binding exists, both prototype and Figma mappings are required and must agree — divergence is a design-qa finding. + +Binding added or restored later: existing `n/a` Design blocks on `verified` screens stay valid as-is; screens not yet past Design Review acquire Figma mappings at their next design touch. + +## 4a. Binding Method — the frame governs arrangement (v1.11) + +Added after BRD-RP-002 F-29 (2026-08-27): five screens carried every node ID of their frames and still were not arranged like them, because the contracts inventoried nodes and described layout in prose, the frames were read through `get_metadata` (names and x/y only), and nothing executable checked arrangement. The rules below make the frame's authority over arrangement measurable. + +1. **Bind with `get_design_context` (layout + screenshot), never `get_metadata` alone.** Metadata cannot show `text-center`, `justify-center`, that a "Mobile header" instance is only the status bar, or that a close control sits above the title. The screenshot is read, not just captured (`M2`). +2. **Three mandatory fields per bound frame in the Design block:** + - `Reading order:` — top-to-bottom list of the frame's elements, node IDs attached (this is what the arrangement test pins); + - `Chrome / presentation:` — bar or no bar, capsule position, sheet vs page, what sits behind a sheet, safe-area; + - `Alignment:` — per text block (centred / start) and per block (vertically centred / anchored). + A node-ID table without these three is **not** a design mapping (§7). +3. **Precedence, stated per authority.** The frame governs arrangement and hierarchy (which action is primary); the design system governs tokens, skins, type steps and motion; copy comes from the handoff/catalogue. Where the frame draws something the DS cannot express, the DS is extended **additively** and the extension is named in the contract. +4. **Deviation = finding + ruling, before landing.** A screen that departs from its frame on arrangement or hierarchy carries a finding row with the Product Owner's ruling; "composition choice" notes and ethics arguments in the contract are not rulings (`M6`, extended to layout). +5. **One arrangement test per designed screen bound to the product design file** — reading order, alignment and roles asserted on the rendered tree (`readingOrder()` pattern), so the next drift fails a test instead of waiting for a screenshot. Validation check 7 (§6) enforces presence; the fidelity review (`M7`, [ui-workflow](../Workflows/ui-workflow.md) C.4) checks content. ## 5. Ownership & Edit Rules - Contract files follow the same role discipline as BRD sections: each block edited by its owning role (table §3); cross-block findings route via owning BRD's S16 `Affects:`. - Registry Status advanced only by the stage that completed the work (UI → `designed`/`prototyped`, Implementation → `implemented`, QA → `verified`). - Contract changes after Design Gate approval = design change → stale-approval rule fires on the owning BRD. +- **Rebind fallout:** a registry mutation replacing or unbinding a resource that Design/API blocks reference ([project-manifest](project-manifest.md) §3) marks those mappings invalid, regresses the affected screens' status (logged in the owning BRD's S16), and fires the stale-approval rule — a swapped design file is a design change, never silent. -## 6. Validation (the six checks — guard `C_CONTRACT`) +## 6. Validation (the seven checks — guard `C_CONTRACT`) Run at Dev Planning entry, automatically, per owning BRD ([../Checklists/screen-contract.md](../Checklists/screen-contract.md) is the executable form): @@ -67,6 +88,7 @@ Run at Dev Planning entry, automatically, per owning BRD ([../Checklists/screen- 4. Every Frontend mapping references approved DS components only (no unmapped/one-off components). 5. Every API dependency is documented (API block ↔ S11 contract, or explicit `api: none`). 6. Every screen has QA coverage (ACs + edge cases + a11y + responsive entries). +7. Every screen at `designed` or beyond whose Design block binds a frame of the **product design file** has an arrangement test naming the screen ID and `readingOrder` (§4a rule 5) — executable form: the project's `arrangement:check` gate. **Any check fails → workflow stops; validator reports the exact missing mappings; orchestrator routes to the owning stage (registry gap → UX/UI; mapping gap → the block's owner). No partial pass.** @@ -77,3 +99,6 @@ Run at Dev Planning entry, automatically, per owning BRD ([../Checklists/screen- - Reusing a retired SCR-ID. - Contract updated after approval without the stale-approval consequence. - `api:` block silently absent instead of explicit `none`. +- **Node inventory as design mapping** — every Figma node ID listed, reading order / chrome / alignment unspecified; the screen then takes the DS defaults and passes every gate while looking nothing like the frame (F-29). +- Reading a frame through `get_metadata` alone and binding from it. +- A recorded "composition choice" standing in for a Product Owner ruling on a frame deviation. diff --git a/Architecture/toolkit-registry.md b/Architecture/toolkit-registry.md new file mode 100644 index 0000000..f6131df --- /dev/null +++ b/Architecture/toolkit-registry.md @@ -0,0 +1,53 @@ +# Toolkit Registry + +> **Module:** Architecture / Foundation (v1.5) +> **Status:** Stable +> **Purpose:** Single source of truth for **user-global toolkit configuration** — facts shared by every project on this machine. Created once at one-time setup ([../Documentation/notion-setup.md](../Documentation/notion-setup.md)); read by project registration and Resource Binding. Kills the bootstrap circularity: project onboarding never has to discover global resources, and never searches the workspace to find them. + +## 1. Ownership Boundary (the rule this file exists for) + +| Layer | File | Owns | Never holds | +|-------|------|------|-------------| +| **Toolkit Registry** (user-global) | `~/.toolkit/registry.yaml` | BRD database identity, Notion workspace, projects parent page, communication bot presence | secrets, project configuration, feature knowledge | +| **Project Manifest** (per project) | `project-manifest.yaml` at primary repo root | project config + Project Resource Registry ([project-manifest](project-manifest.md)) | user-global facts (inherits them), secrets | + +One direction only: projects **inherit** from the Toolkit Registry; the registry never references individual projects. The BRD database is a *toolkit-level* resource (one DB, all projects — [notion-setup](../Documentation/notion-setup.md) §4 invariant): its identity lives here, and each project manifest **copies** the binding at onboarding for local validation stamps. On mismatch, the Toolkit Registry wins — re-run the affected binding. + +## 2. Location & Lifecycle + +- File: `~/.toolkit/registry.yaml` — user home, machine-local, never inside any repo, never committed. +- Created by one-time setup, immediately after the BRD database is created or first connected. +- Missing registry → project registration and onboarding **stop with the remedy** ("run one-time setup per README") — they never search the workspace and never guess. +- Multi-machine: recreate per machine (one-time setup connects the existing DB — `Connect Existing`, not `Create New`). + +## 3. Schema (normative) + +```yaml +registry_version: 1 +created: 2026-07-26 + +notion: + workspace: + brd_database: # THE one DB for all projects — inherited by every manifest + id: <32-hex database id> # stable identifier — never the display name + data_source: + url: # convenience; id is authoritative + validated: 2026-07-26 # last access check at this level + projects_parent_page: null # optional page id — project identity pages are created + # under it at registration (durable pre-repo home for + # registration fields); null = identity lives in the + # manifest only once the repo exists + +communication: + telegram_bot: none # configured | none — presence flag only; + # the token itself stays in TELEGRAM_BOT_TOKEN env, always +``` + +**Secrets rule:** same as everywhere — tokens never enter this file. + +## 4. Consumption + +1. **Registration** ([../Workflows/project-onboarding.md](../Workflows/project-onboarding.md) step 0) reads `notion.brd_database` to reserve the project code and `projects_parent_page` to create the identity page — no asking, no searching. +2. **Resource Binding** (step 3) inherits the BRD DB binding into `manifest.resources.notion.brd_database` (`binding: connected`, confirmed not re-asked). `Create New` for a BRD DB is legal only when the registry has none — first project ever; the created DB is written here first, then inherited. +3. **Manifest Gate** ([../AI/orchestrator.md](../AI/orchestrator.md) responsibility 0) loads this file before anything else; missing → one-time setup is the only offered action. +4. Onboarding's Telegram question consults `communication.telegram_bot` — `none` → the yes-path first routes through bot creation ([../extensions/telegram/README.md](../extensions/telegram/README.md) Setup 1). diff --git a/Architecture/validation-engine.md b/Architecture/validation-engine.md new file mode 100644 index 0000000..e3afe4e --- /dev/null +++ b/Architecture/validation-engine.md @@ -0,0 +1,260 @@ +# Validation Engine + +> **Module:** Architecture / Foundation +> **Status:** Stable +> **Cloned from:** vendored [`VALIDATION_ENGINE.md`](../design-toolkit/VALIDATION_ENGINE.md) @ `4081c24`; tools in [`../design-toolkit/tools/`](../design-toolkit/tools/). Paths remapped to this toolkit's layout. Re-clone on vendor upgrade. +> **Consumed by:** Workflows/ui-workflow (07, 08), Workflows/flow-visualization (12), Workflows/design-review (10, 11), Checklists/design-qa, Checklists/flow-visualization + +Every tool here exists to move a statement from **asserted** to **checked**. A claim a person can only re-check by reading is an assertion, and it decays silently. A claim anyone can re-check by running a command has an **exit code**. + +Two rules bind every tool in this layer — contract, not advice: + +> **M1 — Every check is rendering-class.** Computed visibility and measured geometry, never DOM presence. A node can exist, lay out, and accept a programmatic click while painting nothing. On the extraction run, **84 of 84 DOM assertions passed against a screen that displayed nothing.** + +> **M3 — A failing probe is a hypothesis, not a finding.** One audit's first run reported **60 failures and 3 were real**. A state probe reported **37 failures and all 37 were the harness.** Confirm at source, correct the instrument, re-run. Never waive, never report unconfirmed. + +--- + +## 1. Where each tool runs + +| State | Tool | Question it answers | +|---|---|---| +| 07 `PROTOTYPE` | `smoke.mjs` | does this view paint? (cheap pre-filter, rule B8) | +| 08 `SELF_AUDIT` | `audit.mjs` | paint · targets · overflow · spill · contrast · scripts · source sweeps → **then read `shots/`** | +| 12 `FLOW_VISUALIZATION` | `navgraph.mjs` | screen → screen | +| 12 | `stategraph.mjs` | within a screen | +| 12 | `stateprobe.mjs` | does the state actually paint? | +| 12 | `annotate.mjs` | at the connector | +| any | `linkcheck.mjs` · `mermaidcheck.mjs` | do the documents' own links and diagrams hold? | +| — | `cdp.mjs` · `config.mjs` | shared layer — not validators; they are why the validators have zero dependencies | + +## 2. Shared conventions + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | No findings at or above `--fail-on`. | +| `1` | Findings at or above `--fail-on`. | +| `2` | **Tool error** — missing input, unparseable config, unknown flag. **Not** a product defect. | + +**Distinguishing `1` from `2` matters: a `2` means the check did not run, which is *unevaluable*, not *passing*.** A gate that reads an exit-2 run as green is a gate that ships unchecked bytes. + +### Severity ladder + +`blocking` → `major` → `advisory`. `--fail-on ` names the lowest rung that causes a non-zero exit; default `blocking`. + +### Common flags + +| Flag | Effect | On | +|---|---|---| +| `--root ` | Override the project root. Otherwise `$TOOLKIT_ROOT` → nearest ancestor containing `toolkit.config.json` → cwd. | all | +| `--json ` | Machine-readable output. | navgraph, stategraph, stateprobe, annotate, audit | +| `--md ` | Human-readable report. | navgraph, stategraph, annotate | +| `--fail-on ` | Severity threshold. | navgraph, stategraph, annotate | +| `--shots ` | Screenshot destination. | audit, stateprobe | +| `--plan ` | Explicit audit plan. | audit | +| `--quiet` | Suppress progress; the exit code still holds. | all | + +### Configuration + +No tool contains a product-specific value. Everything comes from the **generated** `toolkit.config.json` at the project root ([ui-workflow §0.3](../Workflows/ui-workflow.md)), merged over defaults section by section, every path absolutised once — so no tool ever joins a path itself. + +**Change a convention in the config, never in a tool.** A prototype whose views are invisible to the harness contract reports as *blank*, which is indistinguishable from the defect the contract exists to catch. + +--- + +## 3. `smoke.mjs` — build-time paint check · state 07 · rule B8 + +Per `(page, view)` pair: does the view **paint** (computed display/visibility + box above 100px) · is the **right** view active (`data-view`) · **console errors** (plus local responses ≥ 400, benign filtered by name) · **tap targets** below `audit.tapTargetFloorPx` · **horizontal overflow**. It prints the `data-sid` the page reports — the first place registry ↔ prototype id drift becomes visible. + +```bash +node design-toolkit/tools/smoke.mjs "signin:main,error,reset" "home:dash,stack" +``` + +| Output | Means | Fix | +|---|---|---| +| `BLANK` | The view exists and paints nothing — almost always `.active` never added, so the container stays `visibility:hidden`. Defect class **B6**. | Wire the activation. **Do not lower the paint threshold.** | +| `view: null` | Nothing matched `viewSelector` + `activeClass`. | The prototype does not honour the harness contract (B7b). Fix the markup, or change the contract in the config — never in the tool. | +| `small:…` | Interactive element below the floor. | Raise it, or confirm the measurement reads a deliberate `::after` hit-area expansion. | +| `H-OVERFLOW` | The document scrolls horizontally at the review viewport. | Usually a fixed width or an un-wrapped row. Genuine scroll rails are a known false positive — §9. | +| `errs:404 …/favicon.ico` | Environment noise. | Add the name to `audit.benignConsole`. Filter **by name**, never wholesale. | + +**Why it exists:** the audit is expensive — it drives every screen across every pass and produces screenshots a human must read. Spending that budget on defects assembly could have caught is waste. + +## 4. `audit.mjs` — the rendering-class audit · state 08 · rules M1–M4 + +Per driven URL, entirely from computed style and measured geometry: **paints** · **no console errors** · **tap targets** (zero-size, `[hidden]`, `visibility:hidden`, `tabindex="-1"` excluded — not reachable targets) · **no h-overflow** · **no content spill** (`overflow:visible` with `scrollHeight > clientHeight` paints over neighbours, which structural assertions never see) · **contrast** on **composited** backgrounds (semi-transparent layers alpha-composited up the ancestor chain; gradients counted as skipped rather than guessed) · **script fonts** (every `product.scripts` range resolves on a stack matching `fontMatch` — CSS falls back per glyph) · **id matches registry** (`data-sid` vs the plan row). + +Plus three **source sweeps** (M4), invisible on any single screen: **off-palette** (hexes outside `audit.colorAllowlist`, after stripping CSS id/class selectors — `#feed` is a selector) · **duplicate-keys** (keys defined more times than `product.locales.length`; the later definition silently wins, and one locale can hide it completely) · **network-call-site** (`fetch` / `XHR` / `WebSocket` / `sendBeacon` / `EventSource` — if the prototype issues real requests, the handoff must say so). + +And a **screenshot per driven URL per pass**. + +```bash +node design-toolkit/tools/audit.mjs --shots design/prototype//shots +``` + +What to drive comes from the audit plan (seed from [vendored template](../design-toolkit/templates/audit-plan.json)); its `passes` array is how M2's **locale × theme × reduced-motion × state** matrix is expressed — every row driven once per pass, every combination getting its own screenshot. + +### The two steps that make this an audit rather than a rumour + +1. **Confirm each failure at source.** Work the false-positive catalogue (§9) first. Correct the harness, re-run, and record the correction in the report's **Harness corrections** section — an uncorrected harness re-reports the same noise next run. +2. **Read the screenshots.** Every one. Required audit step, not a supplement. + +| Output | Cause | Fix | +|---|---|---| +| `prototype dir not found` (**exit 2**) | State 07 has not run, or `paths.prototype` is wrong. | Check the config. **Exit 2 means the check did not run.** | +| `no audit-plan and no state-machines` (**exit 2**) | Nothing to drive. | Seed the plan and list your URLs. | +| `the plan drives nothing` (**exit 2**) | Plan parsed, no `screens` and no `states`. | Populate it. | +| `painted=false` on an empty state | Paint floor tuned to a busy screen. | An empty state is **sparse by design**. Keep the floor low; do not raise it to silence the check. | +| Hundreds of overflow findings | Horizontal scroll rails; `genuinelyClipped: 0`. | Check overflow **ancestry** before reporting. | +| Off-palette hexes across every file | Demo bar / device bezel — harness chrome. | The sweep is **file**-level: chrome in its own file goes in `review.harnessFiles`; chrome inside a product file earns its colours from the allowlist like anything else. | +| A 20px target with `::after{inset:-12px}` | Explicit hit-area expansion. | Measure the hit area, not the box. Confirm at source. | +| One screen fails in one theme, renders on re-run | Timing flake. | Re-run before reporting. **Two stable consecutive runs is the bar.** | +| `duplicate-keys` on a legitimately repeated key | Threshold is `product.locales.length`. | Make sure every locale is listed. The tool reports the count; the reviewer rules it (M6). | + +## 5. `navgraph.mjs` — the navigation derivation · state 12 · rules W1, E1–E5 + +Reads the registry export and **derives** the navigation model. Not an authoring surface: every edge traces to a registry cell. + +| Code | Severity | Meaning | +|---|---|---| +| `N1-broken-edge` | blocking | `navigates_to` / `entry_from` names a screen not in the registry. | +| `N2-orphan` | blocking | No inbound edge and no external entry — the screen is unreachable. | +| `N2b-inbound-only-declared` | major | Reachable only via `entry_from`; no source names it in `navigates_to`, so the forward edge cannot be drawn. Reachable in the product, broken in the registry. | +| `N3-asymmetric` | major | `entry_from` claims a source that does not name it back. A forward edge is missing. | +| `N3b-backedge` | advisory | Forward edge exists, `entry_from` does not name it. The map draws correctly; the column a developer reads to answer *"who can send me here"* is stale. | +| `N4-terminal` | major | No outbound edge and no recorded terminal justification. | +| `N8-lane` | major / advisory | Screens with no swimlane assignment. Major when the lane file exists. | +| `N9-deeplink` | major / advisory | A flow page exposes no query hook at all (major — its states cannot be re-driven after handoff), no `?view` hook (advisory), or no page resolved for a prefix (advisory). | +| `N10-unparsed` | advisory | A registry cell carries prose where an id belongs — a token could not be fully machine-read, so an edge silently drops. | +| `N11-state-vocab` / `N11-state-syntax` | advisory | State labels outside the closed set, or not `canon` / `canon{qualifier}`. | + +Derived, not authored: `crossFlow` (E2) · `heat` (E3) · `states` (E5) · `deepLinks` (E4 — the hooks each page **actually reads**). + +| Finding | Fix | +|---|---| +| `N1-broken-edge` | **Fix the registry** — if a route belongs in the map and not in the registry, the registry is what is wrong. | +| `N2-orphan` | Add the inbound route to the source's `navigates_to`, or declare the external entry (`app launch`, `deep link`, `push notification`) in `entry_from`. | +| `N3-asymmetric` | Verify against the flow graphs **and** the prototype first. Exactly one of the two is wrong and the tool cannot tell which. | +| `N8-lane` | Assign in the lane file. **Never guess a lane** — a wrong lane reads as a ruling about who owns a screen. | +| `N9-deeplink` (major) | The flow has no hooks at all. This is a state 07 **B2** failure. A debt item once recorded this for *one* flow; the scan found it in **three**. | +| `N10-unparsed` | Hand-editing put prose where an id belongs. A token like `START-02/03 skip` carries a second id the pattern cannot see. | +| `N11-*` | **Normalize to the closed vocabulary before generating anything.** | + +## 6. `stategraph.mjs` · `stateprobe.mjs` — inside a screen · state 12 · rules E5, W9, M1 + +`navgraph` answers *which screen leads to which*. `stategraph` answers one level down: within a screen, which states exist, what moves between them, how a developer or QA reaches each. + +**The node set is derived; the edge set is authored with evidence.** The registry owns the state **set**; the state-machines file owns the **transitions**, each carrying `evidence` as `file:line` into the frozen prototype and each state carrying the `hook` that drives it. Every authored claim is checked back against the bytes. + +Codes `S0`–`S9`: machine per screen · node set == registry and vocabulary-legal · initial declared and real · endpoints + trigger `kind` (`user`/`system`/`entry`/`data`) · evidence resolves to a real line · hook parameter actually read · reachable or `entry_only` · outbound or `terminal` · id drift · declared-but-unbuilt with the absence evidenced. + +| Finding | Fix | +|---|---| +| `S1` node set differs | The registry owns the set — reconcile **there**. | +| `S4` evidence does not resolve | The cited line moved or the file was rebuilt. Re-cite against current bytes — this is the check that stops annotations from quietly aging. | +| `S5` hook not read by the page | Documented and not implemented: a state 07 **B2** gap, not a documentation gap. | +| `S6` unreachable | Flag `entry_only` if that is the truth, or add the path. | +| `S8` id drift | **Record it, do not reconcile it** — renumbering is a registry decision. | +| `S9` declared but unbuilt | Keep it, dashed. **Do not delete the state to make the report clean.** | + +`stateprobe.mjs` then drives every hook URL headlessly and asserts the active view is computed-visible **with ink on it**. `stategraph` proves the hook is *read*; **a hook that seeds state is not a hook that shows it.** The probe also reads back the prototype's own id readout at every load — that is how drift gets **measured** instead of asserted (11 observations on the extraction run, confirming one known conflict and opening the same class on a second flow). + +First-run failures on this probe are **almost certainly the harness**: the extraction set's first run reported 37 failures and every one was the instrument. Corrected, not waived. + +## 7. `annotate.mjs` — developer annotations · state 12 · rule E6 + +| Finding | Fix | +|---|---| +| `E1` blank field | Fill it, or write `UNKNOWN`. **Blank fails; `UNKNOWN` passes and is counted.** | +| `E4` citation does not resolve | Landed past end of file, or on a line that has gone blank. Re-cite against the frozen bytes. | +| `E8` `nav` = `UNKNOWN` | No call site found for a route the registry claims. Either the control is missing (a real defect) or the citation names the wrong place. | +| `E11` `api` claim vs sweep | The column says `none (simulated)` and the sweep found a request, or vice versa. **The sweep is authoritative** — it re-runs every time. | +| `E13` `hook_only` | Record it. A route reachable only by URL is a real finding for a build team, not a nuisance. | + +## 8. `linkcheck.mjs` · `mermaidcheck.mjs` — document checks + +The tools above validate a *product*; these validate the *documents*, for the same stated reason: a claim nobody re-checks is a claim that rots. A dead link inside a document set is the same class of defect as a dead deep-link hook inside a prototype — **the structure names a destination that is not there**, invisible to a reader who does not happen to click. + +| Tool | Code | Severity | Fires when | +|---|---|---|---| +| `linkcheck` | `D1-missing` | blocking | a relative link points at a file or directory that does not exist | +| | `D2-anchor` | blocking | a `#fragment` names no heading in the target file | +| | `D3-dir` | advisory | a link points at a directory with no `README.md` | +| `mermaidcheck` | `D4-type` | blocking | the first token is not a recognised diagram type | +| | `D5-quotes` | major | a line carries an odd number of `"` — an unterminated label | +| | `D6-unclosed` | blocking | a mermaid fence never closes | +| | `D7-parens` | advisory | parentheses inside an unquoted `[label]` | + +**Deliberately not checked** (the scope is stated inside the claim): `http(s):` targets — network state is not a property of a repository, and a check that fails on someone else's outage gets ignored · links inside fenced code blocks — those are examples of link syntax · whether a mermaid block *renders* — this is a syntax-smell check, not a parser; rendering is proved by looking (M2) · whether the writing is any good. + +**Both tools were shaped by their own false positives**, which is M3 applied to the check itself: `linkcheck`'s first version collapsed whitespace when slugifying headings where GitHub replaces **each** space with a hyphen, and reported **167 correct links as broken** — a check that opens with 167 false positives does not get debugged, it gets deleted. `mermaidcheck`'s first version flagged twelve **valid** Mermaid shapes (`db[(Store)]` is a cylinder, `s([Go])` a stadium); compound delimiters are now recognised before the label scan, and the remaining check is advisory. + +--- + +## 9. The false-positive catalogue + +**A failing probe is a hypothesis, not a finding.** Rule each class out before writing anything into a report or a finding. + +| Reported | Reality | What to do | +|---|---|---| +| Hundreds of overflow violations | Inside horizontal **scroll rails** — `genuinelyClipped: 0`. | Check overflow **ancestry**. | +| Off-palette hexes across 8 files | Demo bar and device bezel — **harness chrome**, not app surface. | List chrome **files** in `review.harnessFiles`. The sweep excludes files, not selectors. | +| A `#FEED` colour violation | The CSS **id selector** `#feed`. | A hex scanner must not read selectors. | +| A foreign-stack font token used 27–47× per file | The intended architecture for numerals. | The check itself was wrong. | +| A 20px tap target | `::after{inset:-12px}` — an explicit, commented hit-area expansion. | Measure the **hit area**, not the box. | +| A clipped `` | A deliberate crop — an oversized asset inside `overflow:hidden`. | Confirm at source. | +| A screen failing to render in one theme | **Timing flake** — renders at every settle when measured. | Re-run. **Two stable consecutive runs is the bar.** | +| Console errors on nearly every page | An offline webfont CDN and a missing `favicon.ico`. | Filter benign entries **by name**, never wholesale. | +| Empty states "fail to paint" | A visible-node threshold tuned to a busy screen. | An empty state is **sparse by design**. Lower the threshold. | +| A hook that renders nothing | The hook names a fixture id the catalogue does not contain, and the page threw. | **A wrong fixture is not a product defect.** | + +Every one was **corrected in the harness and the run repeated — not waived.** Corrections are recorded in the audit's *Harness corrections* section. + +## 10. Waivers + +A rule may be **waived**, never skipped. A waiver ships only when all three hold: + +1. **The user grants it.** The machine cannot waive its own rules. +2. It is written into the deliverable's **Known limitations** *and* opened as a **numbered debt item**, with an id both sides can cite. +3. It states **what would close it**. + +Two validations waived at one gate on the extraction run — audit currency, and a missing traceability matrix — each riding a numbered debt item. **Both were closed the next day.** One by an audit re-run that immediately found three more real defects; the other by a backfill reporting 26 requirements / 90 ACs with 0 unmet. + +> **The waiver was never the problem. The silence would have been.** + +**What a waiver is not:** a way to make a failing check pass. **A failing check that has not been confirmed at source is not eligible for a waiver — it is eligible for §9.** + +## 11. Running the full suite + +```bash +# state 07 — before handing to the audit +node design-toolkit/tools/smoke.mjs "signin:main,error,reset" "home:dash,stack" + +# state 08 — then READ design/prototype//shots/ +node design-toolkit/tools/audit.mjs --shots design/prototype//shots + +# state 12 — ORDER MATTERS: annotate reads navgraph.json +node design-toolkit/tools/navgraph.mjs --fail-on major +node design-toolkit/tools/stategraph.mjs --fail-on major +node design-toolkit/tools/stateprobe.mjs +node design-toolkit/tools/annotate.mjs --fail-on major +``` + +### As a gate check + +```bash +set -e +node design-toolkit/tools/navgraph.mjs --fail-on major --quiet +node design-toolkit/tools/stategraph.mjs --fail-on major --quiet +node design-toolkit/tools/stateprobe.mjs --quiet +node design-toolkit/tools/annotate.mjs --fail-on major --quiet +echo "READY FOR DEVELOPMENT — scoped to: " +``` + +**Never a bare "handoff ready".** The status is scoped to the flows named in `scope`, and the scope goes **inside** the claim. + +### What the gate reads + +The picture is persuasive and proves nothing. What passes the gate is the **report plus the exit code**, with every finding either cleared or carrying a named waiver with a rider debt item. **A picture that looks right over a report that says `2 blocking` is the exact failure state 12 exists to prevent.** diff --git a/Architecture/workflow-state-machine.md b/Architecture/workflow-state-machine.md index d59765b..14cc12f 100644 --- a/Architecture/workflow-state-machine.md +++ b/Architecture/workflow-state-machine.md @@ -24,11 +24,15 @@ The 13-stage lifecycle every BRD moves through. Machine state lives in Notion pr BRD stages run per-BRD; these run **once per project** (re-run on evolution) and gate the whole pipeline: ``` +TOOLKIT_SETUP (once ever: BRD DB + Toolkit Registry) + ↓ PROJECT_ONBOARDING → INTEGRATION_VALIDATION → MANIFEST_GENERATED + ↑ +MANIFEST_V1 ──(Manifest Gate detects old version)──→ MIGRATION ──→ MANIFEST_GENERATED (v2) ``` -- Executed by [../Workflows/project-onboarding.md](../Workflows/project-onboarding.md) + [../Workflows/integration-validation.md](../Workflows/integration-validation.md); state lives in `project-manifest.yaml` (`onboarding.status`), not in Notion Status values. -- Guard **`C_MANIFEST`** (see §4) blocks BRD pickup for any project without a complete, validated manifest. No BRD, workflow, or skill executes before it. +- Executed by [../Workflows/project-onboarding.md](../Workflows/project-onboarding.md) (+ its §Migration) + [../Workflows/integration-validation.md](../Workflows/integration-validation.md); state lives in `project-manifest.yaml` (`onboarding.status`, `manifest_version`) and `~/.toolkit/registry.yaml` ([toolkit-registry](toolkit-registry.md)), not in Notion Status values. +- Guard **`C_MANIFEST`** (see §4) blocks all project work without a complete, validated, current-version manifest. No BRD, workflow, or skill executes before it. Version migration is a *gate outcome*, not a user chore — the Manifest Gate runs it automatically ([../AI/orchestrator.md](../AI/orchestrator.md) responsibility 0). ## 2. State Catalog @@ -40,9 +44,9 @@ Each state maps to one Workflow module (Phase 1 build). Format per state: primar | 01 | `Analysis` | business-analysis | S01 seed | S01–S06 populated; ACs falsifiable | Clarification Gate (only on blocking ambiguity) | | 02 | `Planning` | product-planning | S01–S06 valid | S03 prioritized; S06 risks scored; direction recommendation in S16 | **Direction Gate** (human) | | 03 | `Design` | ux-workflow → ui-workflow (runs [design-state-machine.md](design-state-machine.md) states 04–08) | Direction approved | S07, S08, S09 populated; prototype link; self-audit pass | — (machine self-gates via SELF_AUDIT) | -| 04 | `Design Review` | — (human review, orchestrator-managed) | Self-audit verdict `pass` | Approval `design` granted, or structured change requests in S16 | **Design Gate** (human) | +| 04 | `Design Review` | [design-review](../Workflows/design-review.md) (runs [design-state-machine.md](design-state-machine.md) states 09 `USER_REVIEW` + 10 `REVISION` + 11 `FINAL_OUTPUT`) | Self-audit verdict `pass` | Approval `design` granted with freeze hashes + `reads_versions`, or structured change requests in S16 | **Design Gate** (human) | | 05 | `Dev Planning` | frontend-planning / backend-planning | Design approved | S10, S11 populated; plan traceable to S03 + S07/S08 | — | -| 06 | `Implementation` | implementation | S10–S11 valid; branch created | Code on branch; S12 progress entries; deviations logged | — | +| 06 | `Implementation` | implementation → security-certification | S10–S11 valid; branch created | Code on branch; S12 progress entries; deviations logged; **S14 Security Certificate `certified`** | — (machine self-gates via `C_SECURITY`) | | 07 | `QA` | qa | Implementation complete claim | S13: every AC verified `pass`/`fail`; bugs filed with severity | — | | 08 | `Tech Review` | code-review | S13 zero open blockers | S14 review summary; concerns; verdict | — | | 09 | `PR` | git | Tech review verdict `approve` | PR opened from template; BRD `PR` property set | — | @@ -50,8 +54,12 @@ Each state maps to one Workflow module (Phase 1 build). Format per state: primar | 11 | `Merged` | git + release | Final approval | Branch merged; S15 release notes; BRD frozen sections | — | | 12 | `Released` | release | Merged; deploy done (if applicable) | S15 final; terminal S16 entry | — | +**Conditional sub-state on the `Design Review → Dev Planning` edge.** When `C_HANDOFF_REQUIRED` holds, design state 12 `FLOW_VISUALIZATION` runs between them ([flow-visualization](../Workflows/flow-visualization.md)): the navigation map is derived from the Screen Contract, validated, and put to the **Developer Handoff Gate**. It is not a lifecycle `Status` value — the BRD stays in `Design Review` until the gate resolves, and `Stage Owner` reads `UI Designer (handoff)`. When `C_HANDOFF_REQUIRED` is false (**the default**), the edge is unchanged and the skip is logged in S16. + **Off-path states:** `Blocked` (resumable; `Blocked Reason` set), `Stopped` (deliberate terminal, rationale in S16), `Backlog` (pre-Ready). +**`Blocked Reason` taxonomy (typed, machine-readable prefix):** `resource: ` (Resource Decision pending — [project-manifest](project-manifest.md) §3) · `paused-by-user` · `ceiling: ` · `ambiguity: ` · `error: `. Session entry surfaces every Blocked BRD with its typed reason and unblock action; the Telegram failure trigger fires on every entry into `Blocked`. + ## 3. Transition Table | From | Trigger | To | @@ -64,13 +72,19 @@ Each state maps to one Workflow module (Phase 1 build). Format per state: primar | Planning | recommendation `stop` + human confirms | Stopped | | Design | design machine reaches SELF_AUDIT `pass` | Design Review | | Design | design machine HALT | Blocked | -| Design Review | approval `design` ∧ `C_CONTRACT` pass | Dev Planning | +| Design Review | approval `design` ∧ ¬`C_HANDOFF_REQUIRED` ∧ `C_CONTRACT` pass | Dev Planning | +| Design Review | approval `design` ∧ `C_HANDOFF_REQUIRED` | Design Review (design state 12 `FLOW_VISUALIZATION` runs) | +| Design Review (state 12) | Developer Handoff Gate granted ∧ `C_NAVMAP_CLEAN` ∧ `C_CONTRACT` pass | Dev Planning | +| Design Review (state 12) | unratified registry route / registry ↔ prototype conflict | Design (design machine states 05 / 10) | | Design Review | approval `design` ∧ `C_CONTRACT` fail | owning stage per validator report (Design / Dev Planning owners), S16 logged | | Design Review | change requests | Design (design machine REVISION routing) | | Design Review | reject (direction wrong) | Analysis | -| Dev Planning | plan validated | Implementation | +| Dev Planning | plan validated ∧ `C_RESOURCES` pass | Implementation | +| Dev Planning | `C_RESOURCES` fail | Blocked (`resource: `) until Resource Decision resolves | | Dev Planning | plan exposes design gap | Design | -| Implementation | complete claim + S12 current | QA | +| Implementation | complete claim + S12 current + `C_SECURITY` pass | QA | +| Implementation | `C_SECURITY` fail (`not-certified`) | Implementation (fix findings; counts against `L_QA`) | +| Implementation | certification exposes authz/contract-level flaw | Dev Planning (S16 `Affects: S10`) | | QA | all ACs verified, zero open blockers | Tech Review | | QA | blocker bugs | Implementation (loop `L_QA`) | | Tech Review | verdict `approve` | PR | @@ -80,6 +94,7 @@ Each state maps to one Workflow module (Phase 1 build). Format per state: primar | Human Review | change requests | Implementation (loop `L_HUMAN`) | | Human Review | reject | Analysis | | Merged | release steps done | Released | +| any in-flight | required resource missing / skipped-but-required / unreachable → Resource Decision raised | Blocked (`resource: `) — cleared by the decision, stage resumes where it stopped | | any | unrecoverable error / ceiling breach | Blocked | ## 4. Guards @@ -91,8 +106,12 @@ Each state maps to one Workflow module (Phase 1 build). Format per state: primar | `C_LOOP_OK(loop)` | `Loop Count` < ceiling for that loop. | | `C_SLOT_FREE` | In-flight BRDs (Status between Analysis and Human Review) < 3. | | `C_SECTIONS(ids)` | Required BRD sections exist and are non-empty. | -| `C_MANIFEST` | Project's `project-manifest.yaml` exists, schema-valid, `onboarding.status: complete`, required integrations `validated`, `last_validated` ≤ 30 days (else re-validate first). Checked at every BRD pickup. | +| `C_MANIFEST` | Toolkit Registry present ([toolkit-registry](toolkit-registry.md)); `project-manifest.yaml` exists, `manifest_version` current (older → Manifest Gate runs Migration first, automatically), schema-valid, `onboarding.status: complete`, `resources.status: bound` with required bindings validated and `health: ok` ([project-manifest](project-manifest.md) §3), required integrations `validated`, `last_validated` ≤ 30 days (else re-validate first). Checked at **session entry for any project work — pickup and resume alike**. | | `C_CONTRACT` | Screen-contract validation passes for the BRD's owned screens ([../Checklists/screen-contract.md](../Checklists/screen-contract.md) — all six checks). Checked at Dev Planning entry. Fail → stop + missing-mappings report + route to owning stage. | +| `C_SECURITY` | A [Security Certificate](../Templates/security-certificate.md) exists in S14 with verdict `certified`, and its `certified_commit` **equals the current branch head**. Zero open `blocker` findings; every waiver carries a user grantor + rider debt item. Checked at **QA entry** and again at **Tech Review entry** — a certificate on superseded bytes is not a certificate. Fail → stop, report findings, route to Implementation ([security-certification](../Workflows/security-certification.md)). | +| `C_HANDOFF_REQUIRED` | The design goes to a build audience that was not in the room, so the navigation map is in scope. Source: `project-manifest.yaml` `design.handoff_required` (**default `false`**), overridable per BRD via a `Handoff Required` property. False → design state 12 is skipped, and the skip is logged S16. | +| `C_NAVMAP_CLEAN` | Navigation derivation ([design-state-machine](design-state-machine.md) §10, `navgraph.mjs`) exits clean at the configured severity, or every remaining finding carries a granted waiver + rider debt item. Checked at the Developer Handoff Gate only. | +| `C_RESOURCES` | Every registry slot the plan implies is bound and healthy: repos named by S10/S11, design file behind Design blocks, APIs' backing repo, doc targets the plan writes to. Checked at **Dev Planning exit** — moves resource gaps to the cheapest stop point instead of mid-Implementation. Fail → Resource Decision ([project-manifest](project-manifest.md) §3). | A forward transition fires only when its guard conjunction holds; otherwise the stage's failure path runs (retry → escalate → Blocked). @@ -115,10 +134,13 @@ A forward transition fires only when its guard conjunction holds; otherwise the | Clarification | leaving Analysis with blocking ambiguity | user | answers logged S16 | | **Direction** | entering Design | user | scoped to S01–S06 content seen | | **Design** | entering Dev Planning | user | scoped to prototype + S07–S09 seen | +| **Developer Handoff** *(conditional)* | leaving Design Review for Dev Planning when `C_HANDOFF_REQUIRED` | user | scoped to registry sha + derivation run + prototype versions named in the gate record | | **Final** | merging | user | scoped to PR diff + BRD state seen | **Stale-approval rule:** if gated content changes after approval, the orchestrator removes the approval token from `Approvals` and logs S16. No shipping on stale approval. +**Stale-certificate rule (machine gate, no human token):** the Security Certificate is scoped to the commit it names. Branch head moves → the certificate is stale → delta re-verification and re-issue before the next gate ([security-certification](../Workflows/security-certification.md)). Delta touching auth, payment, PII, data export, or any file carrying an S06 mitigation → full pass, not delta. + ## 7. Parallelism Up to **3 BRDs in-flight** (`C_SLOT_FREE`). Rules: diff --git a/Checklists/design-qa.md b/Checklists/design-qa.md index 96cec96..2279e8d 100644 --- a/Checklists/design-qa.md +++ b/Checklists/design-qa.md @@ -1,26 +1,42 @@ # Checklist — Design QA (Self-Audit) > **Gate for:** design state 08 ([ui-workflow](../Workflows/ui-workflow.md) audit step) → `Design Review`. The machine gating itself before spending human attention. Verdict `pass` requires every box. +> **Normative rules:** `M1`–`M7` (+ `B2`, `B6`, `B8`) in [method-rules](../design-toolkit/docs/method-rules.md); vendored [skill 08](../design-toolkit/skills/08-self-audit/SKILL.md). +> +> **A DOM-assertion suite is not a substitute for looking at the render.** "N/N assertions passed" is a statement about the suite, not about the product. ## Prototype completeness - [ ] Every S07 flow state represented in the prototype (walk the list, tick each) -- [ ] Every transition wired per flows — including all recovery routes +- [ ] Every transition wired per flows — including all recovery routes — **and every destination paints** (`B6`), no boundary mock outliving its boundary - [ ] Every S09 state **reachable by interaction** in the served prototype (not just built) +- [ ] Deep-link hook present per state/variant/error case and recorded in the traceability map (`B2`) - [ ] `run-local.sh` serves it; player walkable start-to-finish without console errors ## Conformance -- [ ] Every prototype element traces to a spec entry (zero un-specced additions) -- [ ] DS pre-build check evidence: every element maps to DS token/primitive/component or a logged Extension Note +- [ ] Every prototype element traces to a spec entry (zero un-specced additions) — and zero leftovers from a superseded rebuild (`B7`) +- [ ] DS pre-build check evidence: every element maps to DS token/primitive/component or a logged Extension Note; the DS is named by source id - [ ] Zero hardcoded values where a DS entry exists (grep the prototype for raw hex/px against token list) - [ ] Cross-state consistency: naming, hierarchy, spacing rhythm, motion language uniform +- [ ] **Arrangement fidelity (`M7`)**: every screen bound to a product-file frame compared to its render on chrome · reading order · alignment · hierarchy · presentation, from the contract's §4a fields, each recorded `match` / `deviates (F-nn + ruling)`; the arrangement test exists per screen (`arrangement:check` green) + +## Audit method (`M1`–`M4`) +- [ ] Tool runs recorded with their **exit codes** — an exit `2` is *unevaluable*, never counted as a pass ([validation-engine](../Architecture/validation-engine.md) §2) +- [ ] Every check is **rendering-class** — computed visibility and geometry, never DOM presence (`M1`) +- [ ] **Screenshots read**, across language × theme × reduced-motion × state (`M2`) — not just captured +- [ ] Every rendered number read against its own copy (`B8`) — progress fills, counters, ceremony states +- [ ] Every failing probe **confirmed at source** before it is reported; harness corrected and re-run; nothing waived unconfirmed (`M3`) +- [ ] **Source swept**, not just the surface (`M4`): duplicate string/config keys across files *and* locales, stale placeholder routes, per-glyph font fallback on the base stack ## Audit dimensions - [ ] Accessibility audit executed (not skipped): keyboard walk complete, focus visible + managed, contrast token-pairs verified, semantics correct - [ ] Reduced-motion variant exercised (OS flag / emulation) — every animation has its fallback - [ ] Every design-relevant AC marked met/unmet **with evidence** - [ ] Findings classified `blocker`/`major`/`minor` in S14 design-audit subsection +- [ ] Conflicts between approved artifacts **recorded as findings with a recommendation**, never silently resolved (`M6`) +- [ ] Frame deviations on arrangement/hierarchy carry a **Product Owner ruling**, not a "composition choice" (`M6` extended to layout, `M7`) ## Verdict - [ ] Zero unresolved `blocker` findings -- [ ] Known limitations listed transparently (they go in the gate package) +- [ ] Verdict **scoped to the bytes audited**: `reads_versions` names the exact prototype version (`M5`) — targeted assertions from a revision round are not an audit +- [ ] Known limitations listed transparently at full strength (they go in the gate package) - [ ] Verdict `pass`/`fail` recorded with rationale in S14 + S16 diff --git a/Checklists/development-ready.md b/Checklists/development-ready.md index 095bbbc..345d477 100644 --- a/Checklists/development-ready.md +++ b/Checklists/development-ready.md @@ -18,6 +18,7 @@ ## Plan executability (S11) - [ ] Every S08 component: file path + reuse class + props contract (real TS) - [ ] Every backend surface: contract per [api-specification](../Templates/api-specification.md) — input/output schemas, error codes → S09 states, auth, idempotency +- [ ] **S06 threat model complete** for every new/changed surface: asset, attacker/abuse, mitigation, verification method — the basis `C_SECURITY` certifies against before QA - [ ] Contracts frozen; frontend plan consumes them by name - [ ] Schema/migration changes have rollback paths - [ ] Test plan: every AC → layer + named test file (zero unmapped ACs) diff --git a/Checklists/flow-visualization.md b/Checklists/flow-visualization.md new file mode 100644 index 0000000..6698fb4 --- /dev/null +++ b/Checklists/flow-visualization.md @@ -0,0 +1,52 @@ +# Checklist — Flow Visualization (Developer Handoff Gate) + +> **Gate for:** design state 12 ([flow-visualization](../Workflows/flow-visualization.md)) on the `Design Review → Dev Planning` edge — **only when `C_HANDOFF_REQUIRED`** ([design-state-machine](../Architecture/design-state-machine.md) §6). Normative rules: `W1`–`W10`, `E1`–`E7` in [method-rules](../design-toolkit/docs/method-rules.md); V-rules in the vendored [skill 12](../design-toolkit/skills/12-flow-visualization/SKILL.md). +> +> **The gate passes on the report, not on the picture** (`W10`). A map that renders beautifully over a derivation reporting broken routes is the failure this gate exists to prevent. + +## Preconditions +- [ ] `Approvals` contains `design` — state 12 maps approved bytes, never bytes ahead of the gate +- [ ] Prototype versions cited here are the versions the audit of record ran on +- [ ] Registry export regenerated this run from the Screen Contract (no hand edits in the derived CSV) + +## Derivation (`W1`) +- [ ] Run order honoured: `navgraph` → `stategraph` → `stateprobe` → `annotate` (**annotate reads `navgraph.json`**) ([validation-engine](../Architecture/validation-engine.md) §11) +- [ ] Graph **derived by tool**, not drawn — `navgraph.mjs` run recorded with its exit code +- [ ] Every connector traces to a registry cell +- [ ] Derivation reconciled against ratified S07 flows; disagreements recorded as findings, not merged +- [ ] Committed derivation **re-derives identically** from the current registry (V13) + +## Coverage +- [ ] Every Screen Contract row owned by this BRD has a frame in a Section (V1) +- [ ] Every derived navigation path exists as a connector (V2); directions match derived edges (V8) +- [ ] No orphan screens (V3); no broken connectors — endpoints exist at current coordinates (V4) +- [ ] All branches terminate; every decision node's branch set is exhaustive (V5) +- [ ] Entry + exit screens identified per Section (V6) +- [ ] State variants drawn, not left to the prototype (`W9`) + +## Presentation contract +- [ ] Section per **journey**, named `FLOW-XXX • Journey Name` (V7, `W2`) — not per feature +- [ ] Layout: left→right traversal order, uniform pitch, 8pt grid, branches vertical (`W3`) +- [ ] Connector styles carry meaning **and the legend ships in the file** (`W4`) +- [ ] Connectors regenerated wholesale this run — zero hand-patched arrows (`W5`) +- [ ] Every frame stamps ID, name, route, feature, flow, version, status **and the prototype version it depicts** (`W6`) + +## Extensions +- [ ] Lane coverage reported; unassigned screens **reported, never guessed into a lane** (V9, `E1`) +- [ ] Cross-feature map derived, not authored (`E2`) +- [ ] Heat **measured** — in-degree + distinct source features (`E3`) +- [ ] Deep-link addressability reported per flow, read out of the implementation (V10, `E4`); a flow with no hooks is a major finding with a rider +- [ ] State vocabulary normalized **before** generation; edge set carries `file:line` evidence the tool resolves (V11, `E5`) +- [ ] No blank annotation field — `UNKNOWN` legal and counted, guessed values not (V12, `E6`) +- [ ] Overview page ships its provenance block: registry sha, derivation run, prototype versions, date (`E7`) + +## Freshness & boundaries +- [ ] Sync recorded as an **edge-set hash diff**, not as a claim (`W7`) +- [ ] Every boundary re-dated this run (`W8`) + +## Gate record +- [ ] Report presented at the gate (not the picture) +- [ ] Findings clean at the configured severity, **or** every remaining finding carries a granted waiver with a rider debt item, a grantor and a closing condition +- [ ] Gate record names registry sha, derivation run, prototype versions — in `reads_versions`, not only in prose +- [ ] `figma: n/a (no binding)` stated explicitly when the design file is unbound (loud degradation, never silent skip) +- [ ] S07 navigation subsection + S14 gate record + S16 entry written diff --git a/Checklists/qa-testing.md b/Checklists/qa-testing.md index a007f5c..6641854 100644 --- a/Checklists/qa-testing.md +++ b/Checklists/qa-testing.md @@ -2,6 +2,10 @@ > **Gate for:** `QA` → `Tech Review` ([qa workflow](../Workflows/qa.md)). Evidence per verdict; no pass without proof. +## Entry preconditions +- [ ] `C_SECURITY` held at QA entry: S14 Security Certificate `certified`, `certified_commit` = branch head at entry ([security checklist](security.md)) +- [ ] Still holds at exit — QA-loop fixes pushed commits → certificate re-issued against the new head before Tech Review + ## Verification (S13) - [ ] Every AC copied verbatim into a S13 row (no paraphrase drift) - [ ] Every AC row: `pass`/`fail`/`blocked` + evidence pointer (test name / command output / screenshot) @@ -14,6 +18,12 @@ - [ ] Async/interruption: slow network, mid-flight abort, double-submit, back-button, refresh-during-operation - [ ] Error language check: no raw internal/provider errors visible anywhere +## Fidelity walk (device, screens with a bound frame) +- [ ] Per screen: the frame beside the shot, compared on chrome · reading order · alignment · hierarchy · presentation; result recorded per screen (`match` / `deviates F-nn`), shots named in walk order +- [ ] Safe area: nothing painted under the status bar or the home indicator on either platform +- [ ] Sheets: present on mount on **every** entry path (push and replace); close control, scrim and hardware back behave per contract; no "view not in window hierarchy" in the iOS log +- [ ] Dev-client floating button hidden before walking (`EXDevMenuShowFloatingActionButton=false`) — it sits on top-right close controls and swallows the tap + ## Exploratory sweep - [ ] Boundaries: empty/max/unicode/paste-junk inputs on every field touched - [ ] Environment: 360px/768px/1280px viewports, keyboard-only pass, reduced-motion flag on diff --git a/Checklists/screen-contract.md b/Checklists/screen-contract.md index e948781..4ef6604 100644 --- a/Checklists/screen-contract.md +++ b/Checklists/screen-contract.md @@ -9,7 +9,7 @@ ## Design mapping (per owned screen) - [ ] Design block complete: DS components + tokens listed; states designed listed -- [ ] Figma mapping present when `manifest.design.figma_file` non-null (frame + component refs); prototype mapping otherwise (§4 Figma-optional rule) +- [ ] Figma mapping present when `manifest.resources.figma.product_design_file` bound (frame + component refs); prototype mapping otherwise ([screen-contract](../Architecture/screen-contract.md) §4 Figma-optional rule) - [ ] Figma and prototype agree where both exist (divergence = design-qa finding, not a tick) ## Frontend mapping (per approved screen) diff --git a/Checklists/security.md b/Checklists/security.md index 3425644..c77f064 100644 --- a/Checklists/security.md +++ b/Checklists/security.md @@ -1,6 +1,25 @@ # Checklist — Security -> **Runs inside:** code-review dimension 4; full pass on auth/payment/data-scoped BRDs. Defensive verification ([security standard](../Standards/security.md)). Residual risk acceptance = user-signed S06/S16 entry, never a silent tick. +> **Gate for:** `C_SECURITY` on the `Implementation → QA` edge — the executable form of [security-certification](../Workflows/security-certification.md). Also runs as code-review dimension 4 ([code-review](../Workflows/code-review.md)), where it **verifies the certificate is current**, rather than repeating it. +> **Standard:** [Standards/security.md](../Standards/security.md) · **Artifact:** [security-certificate](../Templates/security-certificate.md). Defensive verification only. Residual risk acceptance = user-signed S06/S16 entry, never a silent tick. + +## Preconditions +- [ ] `certified_commit` recorded **before** any check ran, and equal to the branch head +- [ ] Scope stated inside the certificate: what it covers, what it does not, diff basis `..` +- [ ] S06 threat model present and covering every new/changed surface in the diff (stale → append S06 first, then certify) + +## Automated evidence (exit codes, not impressions) +- [ ] Secret scan run — tool, version, command, exit code recorded (unavailable → `gap` + closing condition + manual pattern sweep executed) +- [ ] Dependency audit run — findings at/above `fail_on` triaged, not muted +- [ ] SAST / licence checks run where configured; unconfigured recorded as `gap` +- [ ] Typecheck + lint + test suite re-run by the certifier, output referenced +- [ ] **Every hit confirmed at source** before being reported — unconfirmed scanner output is neither a finding nor a waiver +- [ ] Instrument corrected and re-run wherever it was wrong (a broken tool never reads as a clean pass) + +## Threat-model verification (claimed ≠ present) +- [ ] Every S06 mitigation located at `file:line` with its verification method recorded +- [ ] Every threat with no mitigation present → `blocker` finding (never a note) +- [ ] Surfaces added during Implementation appear in the model; additions appended to S06 before certification ## Boundaries - [ ] Every new/changed input surface parses with schema at entry (body/params/headers/webhook/storage/URL state) @@ -19,6 +38,7 @@ - [ ] Server-only modules fenced (`server-only` marker on secret-touching libs) - [ ] Error responses normalized: no stack traces, provider passthrough, or internal state names ([api-design](../Standards/api-design.md) rule 5) - [ ] Responses return needed fields only (no whole-row leaks); no raw PII in logs +- [ ] Secret found in history → rotation started, not just deletion (a deleted secret in a pushed commit is a published secret) ## Injection & abuse - [ ] Queries parameterized; zero string-built queries from input @@ -27,7 +47,13 @@ - [ ] Webhooks: signature verified; duplicate + out-of-order tolerated - [ ] Rate limits on exposed unauthenticated surfaces -## Closure -- [ ] Every S06 security mitigation verified present in code (claimed ≠ implemented) -- [ ] Dependency audit signal triaged (not muted) -- [ ] Vuln found → sibling sweep done + S16 lesson (checklist/standard candidate) +## Verdict & closure +- [ ] Findings classified honestly — `blocker` = auth bypass, injection, secret exposure, data-integrity risk; zero open blockers to certify +- [ ] Every waiver names rule + why + user grantor + rider debt item + closing condition +- [ ] Verdict `certified` / `not-certified` with rationale; certificate written into S14 in the same edit as the S16 entry +- [ ] Vuln found → sibling sweep done, sweep count recorded + S16 lesson (checklist/standard candidate) + +## Currency (re-verification) +- [ ] Branch head moved since issue → certificate marked stale, delta re-verified, re-issued with `supersedes` +- [ ] Delta touching auth / payment / PII / data export / any file carrying an S06 mitigation → **full** pass, not delta +- [ ] At Tech Review: certificate exists, verdict `certified`, `certified_commit` equals the final branch head — else re-open certification before reviewing diff --git a/Checklists/ui-review.md b/Checklists/ui-review.md index e82a204..dd88be8 100644 --- a/Checklists/ui-review.md +++ b/Checklists/ui-review.md @@ -1,9 +1,13 @@ # Checklist — UI Review -> **Gate for:** design state 06 exit ([ui-workflow](../Workflows/ui-workflow.md)) before prototype assembly. +> **Gate for:** design state 06 exit ([ui-workflow](../Workflows/ui-workflow.md)) before prototype assembly. Normative source: vendored [skill 06](../design-toolkit/skills/06-ui-planning/SKILL.md). +> +> **Failure mode #1: a plan built on the wrong design system validates perfectly against it.** Confirm the source id before ticking anything below. ## Coverage +- [ ] Design system named by **source id** in S08 (library/repo + version), not by nickname - [ ] Every S07 flow state maps to a component set (zero unmapped states) +- [ ] Every flow-implied screen registered in the Screen Contract (`SCR-` row + contract file, overlays included) — an unregistered screen is invisible to handoff mapping - [ ] Layout + hierarchy rules defined per state - [ ] Loading/empty/error states have specified UI (skeleton/placeholder/recovery presentation), not just "shows error" @@ -19,4 +23,5 @@ - [ ] Variants expressed as DS variant APIs (`cva`), not boolean-prop explosions - [ ] Motion specified by DS motion tokens + reduced-motion variant named per animated element - [ ] Component inventory consumable by [development-plan](../Templates/development-plan.md) without interpretation +- [ ] Every component used by more than one flow names its owning plan (shared components are cross-flow contracts, or each file re-decides them and they drift) - [ ] S16 stage-exit entry written diff --git a/Checklists/ux-review.md b/Checklists/ux-review.md index eccb978..e36dfb4 100644 --- a/Checklists/ux-review.md +++ b/Checklists/ux-review.md @@ -20,6 +20,11 @@ - [ ] Every decision point: mutually exhaustive branches (no gaps, no overlaps) - [ ] Transitions annotated with triggers + guards +## Decisions carried, not defaulted +- [ ] Every unanswered guard recorded as an open decision (`o-`) — zero branches invented at planning time +- [ ] Facts promised at a flow boundary appear in **both** flows' decision logs +- [ ] Clearance claims state their scope inside the claim ("no dead ends in FLOW-03", not "no dead ends") + ## Strategy - [ ] Accessibility strategy present, feature-specific (focus order, announcements, motion risks) — not boilerplate - [ ] Reduced-motion behavior decided at strategy level diff --git a/Documentation/CHANGELOG.md b/Documentation/CHANGELOG.md index 7f559bc..375bf40 100644 --- a/Documentation/CHANGELOG.md +++ b/Documentation/CHANGELOG.md @@ -4,6 +4,163 @@ All notable toolkit changes. Format: [Keep a Changelog](https://keepachangelog.c ## [Unreleased] +## [1.10.0] — 2026-08-25 + +**Gap sweep against the vendored source.** A double-check of the vendored toolkit's process docs against what the toolkit actually carries found ten missing pieces. All ten are now in. + +### Added +- `Architecture/validation-engine.md` — **the checker catalogue**, cloned from the vendored `VALIDATION_ENGINE.md`. The toolkit previously had a 7-row tool table and nothing else. Now: per-tool checks and failure/fix tables (`smoke`, `audit`, `navgraph`, `stategraph`, `stateprobe`, `annotate`, `linkcheck`, `mermaidcheck`), **exit-code semantics** (`2` = tool error = *unevaluable*, **not passing** — a gate reading exit 2 as green ships unchecked bytes), the severity ladder and `--fail-on`, the common flags, the **false-positive catalogue** (10 classes, each corrected in the harness and re-run — never waived), the **waiver rule** with its missing clause (*a failing check not confirmed at source is not eligible for a waiver*), the **full-suite run order** (`annotate` reads `navgraph.json` — order matters) and the gate-check script ending in a **scoped** `READY FOR DEVELOPMENT`, never a bare "handoff ready". +- `Architecture/design-state-machine.md` §9b **Cross-state rules** — the four rules binding more than one state (scope your clearance claims · an unruled question is carried, never defaulted · a shared component is a cross-flow contract · facts promised at a boundary are contracts), and §9c **Trimming the pipeline** — what may legitimately be skipped (state 12 without handoff; state 02 **per goal** marked `no-research-needed`) and what never may (08 before 09; 09 before 11). +- `Workflows/project-onboarding.md` step 8b — **design harness bootstrap**: generate `toolkit.config.json` from the manifest, seed `design/navmap/` from the vendored templates, verify Node ≥ 22 and Chrome. Without it states 08 and 12 cannot run at all, and a missing Chrome makes every rendering-class check exit 2. + +### Changed +- `Workflows/business-analysis.md` (74 → 167 lines) — **full clone of skills 01 + 02**, the same fix v1.9.0 applied to states 04–12. Adds the `requirements` and `research` output shapes with frontmatter, both V-rule sets, the exit conditions, and the recovery edges that were missing entirely: research revealing a malformed requirement back-transitions to state 01, and repeated fabrication risk downgrades a theme to a **logged gap** rather than silence. +- `Workflows/product-planning.md` (67 → 138 lines) — **full clone of skill 03**: the scored prioritization table with bands, the risk register with named owners, scope contradictions, the decision record where **every decision names what would reverse it**, the cut list, V1–V4, and the gate rule that **unresolved contradictions are presented as unresolved** — a gate answered on a tidied-up picture is not an approval of the real direction. +- `Architecture/screen-contract.md` — **the registry is the spine**: state 12 derives the entire navigation model from these cells, so if the diagram and the derivation disagree the diagram is wrong, and prose where an id belongs is a finding (`N10-unparsed`) that silently drops an edge. Adds the separator rule — `states` is **comma**-separated, `entry_from` / `navigates_to` are **pipe**-separated. +- `Workflows/ui-workflow.md`, `Workflows/flow-visualization.md`, `Workflows/design-review.md`, `Checklists/design-qa.md`, `Checklists/flow-visualization.md`, `Documentation/module-index.md` — cite the validation engine for exit codes, run order and the waiver clause. + +### Fixed — alignment sweep (cloned method vs this toolkit's process) +- `AI/model-routing.md` — **five stages had no model tier at all**: design states 09, 10, 11, 12 and security certification. The orchestrator routes tier per stage, so an unlisted stage had none. Revision triage (10) is **T3** — a misroute costs three cycles, and did; certification is **T3** because a miss ships. Loop escalation now names `L_REVISION` alongside `L_DESIGN`. +- `Workflows/design-review.md` — **one loop, two names**: `L_REVISION` (design machine) *is* `L_DESIGN` (lifecycle), counted in the S16 entry and Notion `Loop Count`, bumped in the same edit. Two counters for one loop is exactly the failure R7 was written by. +- `Architecture/design-state-machine.md` §2 — **version-id prefix table** (`req-` … `cert-`), one prefix per artifact. The clones introduced twelve id shapes with nothing naming them; a gate record that cannot resolve a version id is the defect `reads_versions` exists to prevent. +- `Workflows/code-review.md` — specialist append rights were stated as a blanket `S05/S06/S10`, which the matrix grants to Security Reviewer but **not** to Accessibility Specialist (S06/S10 = read) or Performance Optimizer (S06 = read). Now enumerated per role, with the `Affects:` route for anything outside a row. +- `AI/orchestrator.md` responsibility 4b — where a gate's evidence is a tool run, **read the exit code**: `2` means the check did not run. A stage reporting "checks passed" with no exit code recorded has not produced gate evidence. + +Verified after the sweep: 0 vendored artifact paths (`artifacts/*`, `machine_state.yaml`, `reference/screen-registry.csv`) outside `design-toolkit/` · 0 vendored terminal or gate names (`HALT_BLOCKED`, `HALT_STOPPED`, `Primary User Approval Gate`) · every workflow's claimed BRD sections within its role's matrix rights · every lifecycle stage carries a model tier · 211 md files, 0 broken links. + +### Notes +- States 01–12 are now all cloned rather than summarized. The remaining vendored docs (`ARCHITECTURE.md`, `ARTIFACT_FLOW.md`, `DESIGN_PRINCIPLES.md`, `WORKFLOW_GUIDE.md`) are reference reading; their normative content is in the workflows and the two state machines. + +## [1.9.0] — 2026-08-25 + +**The design method is now the workflow, not a citation of one.** v1.7.0 vendored the design toolkit and *summarized* its skills into the workflow files. Executing a workflow therefore never loaded the hardened method, and prototype output showed it. Every design state's skill is now **cloned in full** into its workflow file. + +### Added +- `Workflows/design-review.md` (603 lines) — **states 09 `USER_REVIEW` + 10 `REVISION` + 11 `FINAL_OUTPUT`**, cloned from the vendored skills. This stage previously had **no workflow file at all** ("orchestrator-managed"), leaving 1,121 lines of method unloaded: the Run Local rule and its player URL evidence, delta classification before asking (G4), waivers-with-riders (G6), honest pass counts (G7), root-cause routing (R3), class-not-instance sweeps (R2), loop counting (R7), the Conflict Mini-Gate (R8), freeze-is-a-hash (P2), audit-ran-on-the-frozen-bytes (P4), and completeness checked against the matrix (P3). +- `Templates/prototype/` — the **Run Local review player** (`run-local.sh`, `serve.py`, `play.html`, README). v1.7.0 told state 07 to "ship run-local.sh" without shipping the file. Carries a toolkit adaptation note: machine state lives in Notion, so the absent state file means live reload is ON by design. +- `Templates/traceability.md` — the seven-table traceability shape state 07 owes: it is state 08's V1/V2 evidence, the Design Gate's review packet, state 12's deep-link source, and state 11's completeness basis. + +### Changed +- `Workflows/ui-workflow.md` (120 → 621 lines) — full clone of skills 06 + 07 + 08. Recovers **B7b, the harness contract** (`.view` / `active` / `data-view` / `data-sid` / `#sid` — what makes registry↔prototype id drift measurable rather than asserted, and what the three harnesses actually read), the B8 self-check command set, the traceability output shape, the `ui-plan` and `audit-report` frontmatter, the STRICT colour allowlist with its BANNED list, all V1–V6 rule sets, and the three recorded-failure-mode catalogues (assembly defects, build-ops, harness false-positives — including the run that reported 60 failures of which 3 were real). +- `Workflows/ux-workflow.md` (90 → 273 lines) — full clone of skills 04 + 05: the `ux-plan` and `flows` output shapes, the edge-case matrix, the reachability/recovery/boundary tables, and the five flow-generation failure modes. +- `Workflows/flow-visualization.md` (100 → 377 lines) — full clone of skill 12: E1–E7 in full (lane derivation, cross-feature seams, measured heat, deep-link scanning, the closed state vocabulary with its qualifier rule, the six annotation fields split by who owns the answer, the provenance block), V1–V13, the `S*`/`E*`/`N*` validation codes, and "what a first derivation finds". +- **§0.3 config bridge** in `ui-workflow.md` — the harnesses read `toolkit.config.json` from the project root. It is now defined as a **generated** file (manifest `design:` block + the BRD's S08 colour allowlist), regenerated on change, never hand-authored. An allowlist that lives only in S08 is an allowlist nothing enforces. +- `Architecture/design-state-machine.md` — states the executable procedure lives in the workflows and points at each clone; `Architecture/workflow-state-machine.md` — `Design Review` now names its workflow module instead of "human review, orchestrator-managed". +- `Documentation/module-index.md`, `Playbooks/full-feature.md`, `Playbooks/design-only.md`, `Skills/ui-designer.md`, `README.md`. + +### Notes +- Clones carry a `Cloned from: @ 4081c24` line. On a vendor upgrade, re-clone — a summarized clone is how this defect happened in the first place. +- Only artifact locations are remapped (vendored `artifacts/*.md` → BRD sections + `design/` repo dirs). Method text is the skills' text. + +## [1.8.0] — 2026-08-24 + +**Security certification before QA** — security stops being review dimension 4 (spent *after* a full QA cycle) and becomes a precondition with an artifact: a certificate issued against a named commit, gating `Implementation → QA`. + +### Added +- `Workflows/security-certification.md` — exit step of Implementation, owned by the Security Reviewer. Freezes the sha, runs the configured evidence with exit codes, verifies every S06 mitigation at `file:line`, classifies findings, issues `certified` / `not-certified`. Loud degradation: an unavailable scanner is a recorded `gap` with a closing condition plus its manual equivalent — never a silent pass. Every scanner hit is a hypothesis confirmed at source before it is reported. +- `Templates/security-certificate.md` — the artifact: `certified_commit`, scope stated inside the claim, automated-evidence table (tool · version · command · exit code · gap), threat-model verification matrix, manual results, findings, waivers (rule · why · user grantor · rider · closing condition), verdict, currency record. +- **Guard `C_SECURITY`** (`Architecture/workflow-state-machine.md` §4) — checked at QA entry **and** Tech Review entry: S14 must carry a `certified` certificate whose `certified_commit` equals the current branch head. Machine gate, no human token; human signs only residual-risk waivers. +- **Stale-certificate rule** — branch head moves → certificate stale → delta re-verification and re-issue. Delta touching `high_risk_scopes` (auth, payment, PII, data-export) → full pass, never delta. +- `Architecture/project-manifest.md` §2 — `security:` block: `scanners.{secrets,dependencies,sast,licenses}`, `fail_on`, `high_risk_scopes`, `rotate_on_secret_hit`. An empty command is a recorded gap by construction. + +### Changed +- `Workflows/implementation.md` — three new duties: each S06 mitigation implemented **in the slice that creates its surface**; every unplanned surface appended to the threat model as it is built; scanners run locally before push. Stage completes at a certificate, not at "tests green". +- `Workflows/backend-planning.md` + `Checklists/development-ready.md` — Dev Planning now produces the **S06 threat model**: surface → asset → attacker → mitigation → **verification method**. A mitigation with no verification method is a wish, and certification bounces it. +- `Workflows/qa.md` — `C_SECURITY` is an entry precondition and re-checked at exit (QA-loop fixes push commits, which stale the certificate). +- `Workflows/code-review.md` — dimension 4 **verifies the certificate is current** and spot-checks the highest-exposure claims, instead of repeating the pass. Anything it catches that the certificate missed is filed against the certification method, so the checklist gains the rule. +- `Checklists/security.md` — rewritten as the `C_SECURITY` validator: preconditions, automated evidence, threat-model verification, the original four dimension sections, verdict + closure, currency/re-verification. Added: rotation (not deletion) on a secret in a pushed commit. +- `Skills/security-reviewer.md` — owns the certificate and its currency; explicit certification boundary (reviewer files, implementer fixes, reviewer re-verifies). +- `Standards/security.md`, `AI/orchestrator.md`, `Architecture/brd-schema.md` (S14 subsections named), `Playbooks/full-feature.md` (step 6b), `Playbooks/hotfix.md` (certification **scope** compresses; certification itself does not), `Documentation/module-index.md`, `README.md`. + +### Notes +- Still 13 stages and 3 human gates — `C_SECURITY` is a machine gate in the `C_CONTRACT` idiom, so no progress counters, Telegram status strings or playbook numbering change. +- A project with every scanner command empty still certifies: the certificate then carries four `gap` lines and the manual pass. The gap is visible to everyone downstream, which is the point. + +## [1.7.0] — 2026-08-24 + +**Design process replaced from source** — the UI/UX design workflows now execute the current 12-state AI Product Design Agent machine, vendored into the repo as its normative process source instead of paraphrased from a 2026-08 snapshot. + +### Added +- `design-toolkit/` — **vendored** design toolkit @ `4081c24` (2026-08-08), verbatim minus `.git/` and `.github/`: the 12-state spec (`docs/workflow.md`), the hardened rule catalogue (`docs/method-rules.md` — `B`/`F`/`M`/`G`/`R`/`P`/`W`/`E` codes), artifact contracts, 12 per-state skills, 22 templates, 10 verification tools, and the `signin` worked example. `design-toolkit/VENDORED.md` states provenance, the two overrides, and the no-hand-patch upgrade rule. +- `Workflows/flow-visualization.md` + `Checklists/flow-visualization.md` — design **state 12 `FLOW_VISUALIZATION`**: the navigation map is *derived* from the Screen Contract by `navgraph.mjs`, validated V1–V13, and put to the new **Developer Handoff Gate**. Conditional on `C_HANDOFF_REQUIRED`, **default off** — a fourth human gate is never imposed silently. +- `Architecture/project-manifest.md` §2 — `design:` block: `handoff_required`, `design_system.{name,source_id}`, `viewport`, `scripts`, `review_port`, `navmap_path`, `prototype_path`, `loops`. Replaces the vendored `toolkit.config.json` as this toolkit's one config per project. +- `Architecture/screen-contract.md` §3 — optional Prototype-block navigation fields (`entry_from`, `navigates_to`, `states`). Additive: `C_CONTRACT` is unchanged, and a missing route is *reported* by the derivation, never guessed. + +### Changed +- `Architecture/design-state-machine.md` — rewritten as the adapter over the vendored spec: states 01–**12**, artifact remapping (incl. `design/navmap//`), artifact discipline (`reads_versions`, sha256 gate records, freeze-is-a-hash), conditions, gates, loops, a method-rule index, a harness map, and an explicit deltas list. +- `Workflows/ux-workflow.md` — unruled guards carried as open decisions; boundary facts recorded in both flows' logs; scoped clearance claims; state 05 stated as screen-free against state 12's screen-only mandate. +- `Workflows/ui-workflow.md` — DS named by **source id** before planning; build rules `B1`–`B8` + Figma traps `F1`–`F3` (deep-link hooks, destination-paints, namespace claims, duplicate keys across locales, token layer as base, supersession deletes); self-audit is **rendering-class** with screenshots read, probes confirmed at source, source swept, verdict scoped to bytes. +- `Workflows/design-system-workflow.md` — DS source id confirmed first; base-layer token/asset completeness (`B5`); supersession strip recorded (`B7`); sweep the class, not the instance (`R2`). +- `Checklists/design-qa.md` rewritten around `M1`–`M6`; `Checklists/ui-review.md` gains the DS-source-id and screen-registration checks; `Checklists/ux-review.md` gains the carried-decisions section. +- `Architecture/workflow-state-machine.md` — `C_HANDOFF_REQUIRED` + `C_NAVMAP_CLEAN` guards, the conditional state-12 edge on `Design Review → Dev Planning`, and the Developer Handoff Gate row. +- `AI/orchestrator.md` — evaluates `C_HANDOFF_REQUIRED` on Design-Gate approval; gate presentation ships the hook table at the Design Gate and the **derivation report, not the picture**, at the Handoff Gate; post-approval deltas classified before they are asked about. +- `Playbooks/full-feature.md` (step 4b, skipped by default) · `Playbooks/design-only.md` (handoff **on** — the deliverable is built from later; re-derive the map before resuming into build). +- `Skills/ui-designer.md`, `Skills/ux-designer.md`, `Skills/design-system-engineer.md`, `AI/mcp-setup.md`, `Architecture/integration-map.md`, `Documentation/module-index.md`, `README.md` — updated for state 12, the vendored source, and the conditional gate. + +### Fixed +- `Architecture/permission-matrix.md` — UI Designer gains **A** on S14. The design-audit subsection (state 08) and the handoff gate record (state 12) are written by that role, and the matrix said read-only; the workflow and the matrix now agree. + +### Notes +- Nothing in the pipeline changes for a project that leaves `design.handoff_required: false`: same 13 stages, same three gates, same `C_CONTRACT`. +- The vendored directory is read-only by policy. Upgrade by re-vendoring the whole tree and reconciling the adapter's deltas — a hand-patched vendor is indistinguishable from a stale one. + +## [1.6.0] — 2026-08-24 + +**Telegram remote execution** — the chat adapter gains an opt-in executor daemon: a message in the bound chat can run Claude Code headless on the host, default-closed behind two independent switches. + +### Added — Telegram remote execution (extension v2, opt-in) +- `extensions/telegram/executor.mjs` — **inbox executor daemon**: consumes `type: "command"` spool events and runs them through Claude Code headless (`claude -p --output-format stream-json`) in the project root, streaming `exec_started` / `exec_progress` / `exec_result` / `exec_error` back through the outbox. Separate process from the adapter — the filesystem spool stays the whole contract, so the executor is optional and removable. +- `extensions/telegram/config.mjs` — shared `communication.telegram` manifest reader, now the single parser for both processes (the adapter's inline copy is gone). Adds the `exec` block: `enabled`, `allowed_user_ids`, `permission_mode`, `allowed_tools`, `timeout_minutes`. +- Chat surface: free text → queued command, `/new ` → fresh Claude session, `/cancel` → SIGTERM the running command. `/status` unchanged. One Claude session is reused per project (`.toolkit/telegram/.session`) so follow-ups keep context. +- `extensions/telegram/*.test.mjs` — 81 `node --test` cases covering parsing, message routing, authorization, session lifecycle, single-flight locking, timeout, and cancellation. + +### Security +- Remote execution is **default-closed on two independent switches** — `exec.enabled: true` and a non-empty `exec.allowed_user_ids`; an empty allow-list authorizes nobody and a missing block refuses all free text. Chat identity (adapter, `chat_id`) and sender identity (executor, `allowed_user_ids`) are enforced separately. +- `permission_mode` defaults to `acceptEdits`; the executor never passes `--dangerously-skip-permissions`. `bypassPermissions` remains available but is documented as granting unrestricted execution to anyone who can post in the bound chat. +- Prompts are passed as a single argv entry to a shell-less spawn; `cwd` is pinned to the project root with no `--add-dir`. Runs are bounded by a single-flight lock (reclaimed if the holder dies) and a `timeout_minutes` SIGTERM. +- The executor never consumes `approval` events — gate decisions remain the orchestrator's, under its stale-approval and scope rules. + +### Changed +- `extensions/telegram/telegram-plugin.mjs` — free-text messages are now spooled as `command` events instead of being silently dropped; outbox sends are chunked to Telegram's 4096-character limit; `formatEvent` takes notifications explicitly and gained the `exec_*` cases; module entry is guarded so the file can be imported by tests without connecting. +- `Architecture/project-manifest.md` §2 — documents `communication.telegram.exec` and the remote-execution rule. + +## [1.5.0] — 2026-08-21 + +**Project Resource Binding** — every project explicitly owns and binds its external resources; the toolkit never searches the user's workspace once a project is onboarded. + +### Added — architecture (flow-review hardening) +- `Architecture/ecosystem-map.md` — **Ecosystem Map**: concept-level bridge between this toolkit and the `paul` / `gsd-*` / `carl-mcp` systems the user also runs (equivalence table, state-ownership boundaries). Informational — no runtime dependency, not executed by the orchestrator. +- `Architecture/toolkit-registry.md` — **Toolkit Registry** (`~/.toolkit/registry.yaml`): user-global config layer owning the BRD DB identity (one DB, all projects = toolkit-level resource), projects parent page, bot presence. Written at one-time setup; inherited by every manifest. Kills the registration↔binding bootstrap circularity — step 0 reads it, never searches. +- **Resource lifecycle model** (`project-manifest.md` §3): `binding` (disposition — immutable decision) split from `health` (`ok`/`unreachable` — runtime, restamped by validation). One **Resource Decision** primitive (connect / create / confirm-absence) covers missing, skipped-but-required, and unreachable slots; per-slot **absence behavior** table generalizes the Figma prototype-only rule; rebind-fallout + rebind-logging rules. +- **Manifest Gate pipeline** (`orchestrator.md` responsibility 0): fixed order — Toolkit Registry → manifest → **version migration** → staleness → C_MANIFEST — at session entry, pickup and resume alike. `Migration (v1→v2)` is now an orchestrator-triggered, seeded, resumable procedure (`project-onboarding.md` §Migration, with per-field seeding table); the "v1 remains pickable" contradiction removed. +- **State machine**: `Blocked Reason` taxonomy (typed: `resource:` / `paused-by-user` / `ceiling:` / `ambiguity:` / `error:`); missing-resource transition (any in-flight → Blocked, resumable, fires Telegram trigger); new guard **`C_RESOURCES`** at Dev Planning exit (plan-implied slots must be bound + healthy — gaps stop at the cheap point, not mid-Implementation). +- **Onboarding end-to-end fixes**: step 0 reads the registry + creates the identity page (circularity gone); GitHub create-new pushes so the default branch exists for validation; step 6 writes `incomplete`, step 9 scaffolds CI + pushes + applies branch protection (`gh api`, actor defined) + stamps complete; unified rebind rule (any mutation = step 3+4+5 for the slot); re-open phrases defined as say-to-Claude routes; Telegram yes-path made executable (user creates chat, `getUpdates` discovery, test-send after manifest write, failure → `deferred` without consuming ask-once). +- Screen contract: pre-existing screens seed as `implemented (pre-toolkit)` (backfill-on-claim rule); §4 keys off "no **healthy** Figma binding"; multi-repo BRD branch/PR contract (`integration-map.md` §3). + +### Added +- `Architecture/project-manifest.md` §3 — **Project Resource Registry**: `resources:` block (manifest_version 2) as the sole home of external resource identity. Slots per provider — Notion (BRD DB **required**, project page, sprint/decision-log DBs), Figma (product design file, design-system library), GitHub (frontend/backend — ≥1 **required** — + optional infrastructure repo), documentation (API/architecture/product), communication (Telegram, stable `communication.*` path kept for the plugin), other MCP-backed resources. Uniform binding record: **stable identifier** (database id / file key / numeric repo id / chat id — never display names) + `binding: connected | created | skipped` + `bound`/`validated` stamps. Explicit-skip rule: optional slots are resolved or skipped, never silently absent, never re-asked, never guessed. +- `Architecture/integration-map.md` §2b — **Project Boundary Rule (hard)**: after onboarding, orchestrator + workflows access only registry resources; workspace-wide Notion search, Figma browsing, and repo listing are forbidden. Missing resource → stop + *connect existing / create new* offer (targeted rebind). §6 gains a per-integration boundary-scope column. +- `Workflows/project-onboarding.md` step 3 — **Project Resource Binding stage**: per-slot Connect Existing / Create New / Skip table with per-provider stable-ID resolution; step 5 binding validation with verbatim `✓ / ○ Skipped` checklist; "After Onboarding — the Boundary Holds" section. Communication step (v1.4 7b) folded into the binding stage; asked-once rule unchanged. +- `AI/orchestrator.md` responsibility 0b + anti-rule — resource-boundary enforcement: registry-scoped access, connect/create escalation, S16 logging. +- Manifest v1→v2 migration path (`project-manifest.md` §1): first pickup offers a binding re-run seeded from existing `notion.*`/`design.*`/`git.repository` values. + +### Changed +- `Architecture/project-manifest.md` — `design:` reduced to code-side config (figma resource identity → `resources.figma`); `notion:` block dissolved into `resources.notion`; `git:` keeps behavior only (`primary_repository` names the manifest-hosting repo slot; identity → `resources.github`); consumption rule 2 (registry-only access) + validation §5 require `resources.status: bound` and stable ids. +- `Architecture/workflow-state-machine.md` — `C_MANIFEST` now also requires `resources.status: bound` with required bindings validated. +- `Workflows/integration-validation.md` — checks resolve via registry ids; new registry re-check row (staleness re-validates bindings, restamps `resources.*.validated`); validation never becomes workspace discovery. +- `Templates/project-configuration.md` — Design/Notion/Git resource questions replaced by a Project Resource Binding section (connect/create/skip per slot); Git Behavior section retains strategy-only fields. +- `Architecture/screen-contract.md` §4 + `Checklists/screen-contract.md` — Figma-optional rule keys off `resources.figma.product_design_file` binding. +- `AI/mcp-setup.md` — Notion toolset drops `notion-search` (boundary); Figma MCP applicability keyed to the registry binding, not BRD mentions; all rows note registry scope. +- `Architecture/context-package.md` — `design.md` source of truth includes `resources.figma.*`. +- `extensions/telegram/README.md` — setup points at the Resource Binding stage; re-open phrases limited to the sanctioned two. +- `Documentation/onboarding.md`, `README.md` — Resource Binding + boundary as first-class architecture concepts; v1.5.0 pins. + +### Fixed +- `extensions/telegram/telegram-plugin.mjs` — manifest reader now strips surrounding quotes from values; previously `chat_id: "-100…"` (as the schema shows) yielded literal quote characters — every send targeted an invalid chat and the allow-list never matched. +- Manifest schema — undefined `notifications.pipeline` flag removed (no event type maps to it; `failures` already covers pipeline-failed). +- `extensions/telegram/telegram-plugin.mjs` — project-name reader now strips inline comments and surrounding quotes; `name: "My Project"` previously reached every notification with literal quote characters. +- `extensions/telegram/telegram-plugin.mjs` — dead `pipeline: true` key dropped from the notifications default (only `approvals` and `failures` are read). + ## [1.4.1] — 2026-07-26 ### Changed diff --git a/Documentation/module-index.md b/Documentation/module-index.md index 4b881b3..99c83cd 100644 --- a/Documentation/module-index.md +++ b/Documentation/module-index.md @@ -6,13 +6,16 @@ Every module, one line. Load only what the task needs — this index is the map, - [brd-schema](../Architecture/brd-schema.md) — Living BRD contract: Notion properties, S01–S16, update modes, decision-log format - [permission-matrix](../Architecture/permission-matrix.md) — 16 roles × 16 sections rights; cross-domain `Affects:` protocol - [workflow-state-machine](../Architecture/workflow-state-machine.md) — 13-stage lifecycle: transitions, guards, loops, gates, parallelism -- [design-state-machine](../Architecture/design-state-machine.md) — design sub-machine (states 01–11), artifacts→BRD remapping -- [integration-map](../Architecture/integration-map.md) — Notion↔Claude↔Git wiring, naming contracts, knowledge layers, onboarding +- [validation-engine](../Architecture/validation-engine.md) — the checker catalogue: per-tool checks + failure/fix tables, exit-code semantics (`2` = unevaluable), severity ladder, false-positive catalogue, waiver rule, run order +- [design-state-machine](../Architecture/design-state-machine.md) — design sub-machine (states 01–12 incl. `FLOW_VISUALIZATION`), artifacts→BRD remapping, method-rule index, harness map; normative process source is the vendored [design-toolkit](../design-toolkit/VENDORED.md) +- [integration-map](../Architecture/integration-map.md) — Notion↔Claude↔Git wiring, naming contracts, knowledge layers, Project Boundary Rule (§2b), onboarding - [versioning](../Architecture/versioning.md) — semver, submodule pin/upgrade, compatibility promise -- [project-manifest](../Architecture/project-manifest.md) — per-project config contract; `C_MANIFEST` gates all BRD work +- [project-manifest](../Architecture/project-manifest.md) — per-project config contract + Project Resource Registry (stable-ID bindings, health, Resource Decision, §3); `C_MANIFEST` gates all BRD work +- [toolkit-registry](../Architecture/toolkit-registry.md) — user-global config (`~/.toolkit/registry.yaml`): BRD DB identity, projects parent page; inherited by every manifest - [screen-contract](../Architecture/screen-contract.md) — SCR registry + 5 mapping blocks; `C_CONTRACT` gates Dev Planning - [stack-profiles](../Architecture/stack-profiles.md) — manifest stack → applicable Standards + honest gaps - [context-package](../Architecture/context-package.md) — generated `context/` session-bootstrap summaries (derived cache, never edited) +- [ecosystem-map](../Architecture/ecosystem-map.md) — concept bridge to paul/gsd-core/carl (informational only, no runtime dependency) ## AI/ (runtime) - [CLAUDE-global](../AI/CLAUDE-global.md) — per-project entry contract + CLAUDE.md stub @@ -22,9 +25,9 @@ Every module, one line. Load only what the task needs — this index is the map, - [model-routing](../AI/model-routing.md) — model tier per stage, escalation rules, cross-model handoffs ## Workflows/ (stage procedures) -- [business-analysis](../Workflows/business-analysis.md) · [product-planning](../Workflows/product-planning.md) · [ux-workflow](../Workflows/ux-workflow.md) · [ui-workflow](../Workflows/ui-workflow.md) · [design-system-workflow](../Workflows/design-system-workflow.md) +- [business-analysis](../Workflows/business-analysis.md) · [product-planning](../Workflows/product-planning.md) · [ux-workflow](../Workflows/ux-workflow.md) (states 04–05) · [ui-workflow](../Workflows/ui-workflow.md) (06–08) · [design-review](../Workflows/design-review.md) (09–11) · [flow-visualization](../Workflows/flow-visualization.md) (12, conditional) · [design-system-workflow](../Workflows/design-system-workflow.md) - [frontend-planning](../Workflows/frontend-planning.md) · [backend-planning](../Workflows/backend-planning.md) · [implementation](../Workflows/implementation.md) -- [qa](../Workflows/qa.md) · [code-review](../Workflows/code-review.md) · [git](../Workflows/git.md) · [release](../Workflows/release.md) · [debug](../Workflows/debug.md) +- [security-certification](../Workflows/security-certification.md) (`C_SECURITY`, before QA) · [qa](../Workflows/qa.md) · [code-review](../Workflows/code-review.md) · [git](../Workflows/git.md) · [release](../Workflows/release.md) · [debug](../Workflows/debug.md) - Per-project: [project-onboarding](../Workflows/project-onboarding.md) · [integration-validation](../Workflows/integration-validation.md) ## Skills/ (roles) @@ -41,13 +44,14 @@ Every module, one line. Load only what the task needs — this index is the map, ## Templates/ (fill-in structures) - BRD: [feature-request](../Templates/feature-request.md) · [product-requirement](../Templates/product-requirement.md) · [technical-specification](../Templates/technical-specification.md) · [development-plan](../Templates/development-plan.md) · [bug-report](../Templates/bug-report.md) · [release-notes](../Templates/release-notes.md) -- Artifacts: [design-handoff](../Templates/design-handoff.md) · [component-documentation](../Templates/component-documentation.md) · [api-specification](../Templates/api-specification.md) · [pull-request](../Templates/pull-request.md) · [retrospective](../Templates/retrospective.md) +- Artifacts: [design-handoff](../Templates/design-handoff.md) · [component-documentation](../Templates/component-documentation.md) · [api-specification](../Templates/api-specification.md) · [security-certificate](../Templates/security-certificate.md) · [pull-request](../Templates/pull-request.md) · [retrospective](../Templates/retrospective.md) - Onboarding + contract: [project-configuration](../Templates/project-configuration.md) · [design-mapping](../Templates/design-mapping.md) · [frontend-mapping](../Templates/frontend-mapping.md) · [api-mapping](../Templates/api-mapping.md) +- Design build: [prototype/](../Templates/prototype/README.md) (the Run Local player — `run-local.sh` · `serve.py` · `play.html`) · [traceability](../Templates/traceability.md) ## Checklists/ (gates) -- Stage exits: [analysis](../Checklists/analysis.md) · [ux-review](../Checklists/ux-review.md) · [ui-review](../Checklists/ui-review.md) · [design-qa](../Checklists/design-qa.md) · [development-ready](../Checklists/development-ready.md) · [qa-testing](../Checklists/qa-testing.md) · [code-review](../Checklists/code-review.md) · [release](../Checklists/release.md) +- Stage exits: [analysis](../Checklists/analysis.md) · [ux-review](../Checklists/ux-review.md) · [ui-review](../Checklists/ui-review.md) · [design-qa](../Checklists/design-qa.md) · [flow-visualization](../Checklists/flow-visualization.md) · [development-ready](../Checklists/development-ready.md) · [qa-testing](../Checklists/qa-testing.md) · [code-review](../Checklists/code-review.md) · [release](../Checklists/release.md) - Dimensions: [accessibility](../Checklists/accessibility.md) · [performance](../Checklists/performance.md) · [security](../Checklists/security.md) -- Guards: [screen-contract](../Checklists/screen-contract.md) (`C_CONTRACT` validator) +- Guards: [screen-contract](../Checklists/screen-contract.md) (`C_CONTRACT` validator) · [security](../Checklists/security.md) (`C_SECURITY` validator) ## Prompts/ (invocation patterns) - [planning](../Prompts/planning.md) · [research](../Prompts/research.md) · [architecture](../Prompts/architecture.md) · [design](../Prompts/design.md) · [development](../Prompts/development.md) · [refactoring](../Prompts/refactoring.md) · [testing](../Prompts/testing.md) · [debugging](../Prompts/debugging.md) · [documentation](../Prompts/documentation.md) · [review](../Prompts/review.md) · [prompt-improvement](../Prompts/prompt-improvement.md) @@ -55,8 +59,13 @@ Every module, one line. Load only what the task needs — this index is the map, ## Playbooks/ (compositions — the only composing layer) - [full-feature](../Playbooks/full-feature.md) · [parallel-brds](../Playbooks/parallel-brds.md) · [hotfix](../Playbooks/hotfix.md) · [design-only](../Playbooks/design-only.md) +## design-toolkit/ (vendored — normative design process source) +- [VENDORED](../design-toolkit/VENDORED.md) — provenance, the two overrides (artifact store, machine state), upgrade rule. **Do not edit files in that directory.** +- [docs/workflow.md](../design-toolkit/docs/workflow.md) — the 12-state spec · [docs/method-rules.md](../design-toolkit/docs/method-rules.md) — hardened rule catalogue (`B`/`F`/`M`/`G`/`R`/`P`/`W`/`E` codes) · [docs/artifact-contracts.md](../design-toolkit/docs/artifact-contracts.md) — frontmatter + gate-record fields +- [skills/](../design-toolkit/skills/) — one skill per state · [tools/](../design-toolkit/tools/) — verification harness (exit codes, not prose) · [templates/](../design-toolkit/templates/) — artifact templates + ## extensions/ (opt-in, not core) -- [telegram](../extensions/telegram/README.md) — mobile gate approvals + notifications via file-spool adapter; `telegram-plugin.mjs` +- [telegram](../extensions/telegram/README.md) — mobile gate approvals + notifications via file-spool adapter, plus opt-in remote execution; `telegram-plugin.mjs` (adapter), `executor.mjs` (runner daemon), `config.mjs` (shared manifest reader) ## Documentation/ (meta) - [onboarding](onboarding.md) — add the toolkit to a project + run the first BRD diff --git a/Documentation/notion-setup.md b/Documentation/notion-setup.md index af5bbaf..447a274 100644 --- a/Documentation/notion-setup.md +++ b/Documentation/notion-setup.md @@ -70,6 +70,10 @@ and report what you built with links. Verify against §1 before first real use (option spelling matters — the orchestrator matches values exactly). +## 3b. Write the Toolkit Registry (required — closes the setup) + +Immediately after the DB exists (created or connected), record its identity in the **Toolkit Registry** — `~/.toolkit/registry.yaml` per [toolkit-registry](../Architecture/toolkit-registry.md): database id, data source, workspace; optionally a projects parent page (durable home for project identity pages — recommended if you register projects before their repos exist). Project registration and Resource Binding read this file; **without it, onboarding stops at step 0 by design** — the toolkit never searches your workspace to find the DB. On a new machine, re-run this section against the existing DB (connect, not create). + ## 4. Invariants (re-stated from schema) - Status values are machine states — never rename casually (rename = toolkit major version). diff --git a/Documentation/onboarding.md b/Documentation/onboarding.md index 1635e1f..4d1d12c 100644 --- a/Documentation/onboarding.md +++ b/Documentation/onboarding.md @@ -1,6 +1,7 @@ # Onboarding — New Project in ~10 Minutes -> Prereqs (once ever): BRD database exists ([notion-setup](notion-setup.md)); toolkit repo has a remote; `gh auth status` ✅; Notion MCP connected ([mcp-setup](../AI/mcp-setup.md)). +> Estimate covers steps 1–3 below; the first BRD (§4) is its own, longer exercise — it proves the loop, not the clock. +> Prereqs (once ever): BRD database exists **and the Toolkit Registry is written** (`~/.toolkit/registry.yaml` — both per [notion-setup](notion-setup.md); onboarding stops without the registry, by design); toolkit repo has a remote; `gh auth status` ✅; Notion MCP connected ([mcp-setup](../AI/mcp-setup.md)). ## 1. Pin the toolkit @@ -11,22 +12,41 @@ cd toolkit && git fetch --tags && git checkout && cd .. git add .gitmodules toolkit && git commit -m "chore: pin toolkit " ``` -## 2. Wire Claude +## 2. Run the onboarding workflow (mandatory — nothing runs without it) -- Copy the stub block from [AI/CLAUDE-global.md](../AI/CLAUDE-global.md) into the project's `CLAUDE.md`; fill: project name, project code (`` for BRD IDs), Notion DB name + Project select value, stack one-liners. -- Create empty `project-overrides.md` at repo root with the header: *"Sanctioned deviations from toolkit Standards/. Every entry: rule overridden → replacement → why."* +```text +Run toolkit/Workflows/project-onboarding.md for this project. +``` + +The workflow interviews once, detect-first, and produces the manifest. It opens with **registration** (step 0 — project name, code, identity; reads the BRD DB from the Toolkit Registry; if you already registered via the README's pre-repo Step 0, it just confirms). Its core stage is **Project Resource Binding**: for every supported resource — Notion (BRD database, project page, optional sprint / decision-log databases), Figma (product design file, design-system library), GitHub (frontend / backend / optional infrastructure repos), documentation (API / architecture / product), Telegram, other MCP-backed resources — you choose: + +- **Connect Existing** — point at what you already have; the toolkit resolves and stores the **stable identifier** (database ID, file key, repo ID, chat ID — never a display name), or +- **Create New** — the toolkit creates it for you (BRD DB per [notion-setup](notion-setup.md), `gh repo create`, Figma file, doc scaffold), or +- **Skip** — optional slots only; recorded explicitly and never re-asked proactively. (If work later genuinely needs a skipped resource, you get exactly one connect / create / confirm-absence decision — never a guess.) -## 3. Wire Notion +Everything bound is access-validated before onboarding can finish: + +``` +✓ Notion BRD Database accessible +✓ Figma Product Design File accessible +✓ GitHub frontend repository accessible +✓ Telegram connected +○ Skipped: infrastructure repo, sprint DB +``` -- Add the project to the DB's `Project` select options. -- That's all — pages created from the DB template are born conformant. +Required bindings (BRD database, ≥1 GitHub repo) failing → onboarding stays incomplete and tells you what unblocks it. -## 4. Wire Git +**Why binding matters:** the resulting registry is a hard boundary. After onboarding, the toolkit only ever touches what's registered — it never searches your Notion workspace, browses your Figma teams, or lists your GitHub repos ([Project Boundary Rule](../Architecture/integration-map.md) §2b). If a stage ever needs something unregistered, it stops and asks you to connect or create it — it never guesses. -- Protect `main`: PR-only + required CI ([git-strategy](../Standards/git-strategy.md) rule 11). Solo repos too. -- CI runs minimum: typecheck, lint, test, build. +The workflow also scaffolds for you: project `CLAUDE.md` block, `project-overrides.md` (*"Sanctioned deviations from toolkit Standards/. Every entry: rule overridden → replacement → why."*), `screens/registry.md` with optional seeded screens, and `context/` summaries. Its closing step (9) **pushes created repos, scaffolds CI** (minimum: typecheck, lint, test, build) where none exists, **applies branch protection** (PR-only + required CI, per [git-strategy](../Standards/git-strategy.md) rule 11 — the orchestrator does this via `gh api`, solo repos too), commits the lot, and only then stamps onboarding complete. -## 5. First BRD (the real onboarding test) +If you enabled Telegram: **start the daemon** ([extension README](../extensions/telegram/README.md) Setup 4) — without it, notifications spool but never send. + +## 3. Verify Git wiring + +Onboarding applied protection for you — verify: PRs required on `main`, CI checks listed as required. Adjust in repo settings only via `project-overrides.md`-sanctioned deviations. + +## 4. First BRD (the real onboarding test) 1. New page from DB template → fill [feature-request](../Templates/feature-request.md) seed → `Ready` ✓. 2. Start a Claude session in the project: @@ -36,12 +56,18 @@ git add .gitmodules toolkit && git commit -m "chore: pin toolkit " ``` 3. Follow the machine: it will run stages, stop at your three gates (Direction → Design → Final), and close with S15. First run, pick something small — the point is proving the loop, not shipping big. -## 6. Health signs (first BRD retro-lite) +## 5. Health signs (first BRD retro-lite) - ✅ Every stage exit wrote S16; you can reconstruct the run from the BRD alone. - ✅ Gates presented decision packages (not "approve?" blobs). - ✅ No content written outside matrix rights; findings landed at discovery time. +- ✅ Every external touch (Notion page, Figma file, repo) was a registry resource — nothing outside the binding. - ⚠️ Claude improvised a missing input instead of bouncing → that's an orchestrator-contract violation; note it, tighten with [prompt-improvement](../Prompts/prompt-improvement.md). +- ⚠️ Claude searched the workspace for an unbound resource instead of asking connect-or-create → boundary violation; same treatment. + +## Project evolves — rebind, don't re-onboard + +New Figma file, moved database, extra repo → targeted re-run of the Resource Binding slot (`toolkit onboard --update`). Un-skipping a slot works the same way. Full re-onboarding only when the manifest is untrusted. ## Upgrading a project later @@ -49,7 +75,7 @@ git add .gitmodules toolkit && git commit -m "chore: pin toolkit " cd /toolkit && git fetch --tags && git checkout && cd .. git add toolkit && git commit -m "chore: upgrade toolkit -> " ``` -Major bumps: read the CHANGELOG migration notes first ([versioning](../Architecture/versioning.md)). In-flight BRDs finish under their pinned version unless you decide otherwise (log in S16). +Major bumps: read the CHANGELOG migration notes first ([versioning](../Architecture/versioning.md)). Projects on manifest v1 (pre-v1.5): first pickup offers the Resource Binding migration — existing manifest URLs seed the registry, stable IDs get resolved and validated ([project-manifest](../Architecture/project-manifest.md) §1). In-flight BRDs finish under their pinned version unless you decide otherwise (log in S16). ## Removing / resetting diff --git a/Documentation/specs/2026-08-26-feature-prototype-design.md b/Documentation/specs/2026-08-26-feature-prototype-design.md new file mode 100644 index 0000000..9820178 --- /dev/null +++ b/Documentation/specs/2026-08-26-feature-prototype-design.md @@ -0,0 +1,324 @@ +# Design — the feature prototype + +> **Status:** approved design, not yet implemented +> **Date:** 2026-08-26 +> **Scope:** design sub-machine states 07 · 08 · 09 · 12, adapter layer only +> **Spec location note:** the brainstorming default is `docs/superpowers/specs/`. This repo +> already roots its prose at `Documentation/`, and a second docs root in a toolkit this +> opinionated about structure is a defect, not a convenience. Filed here instead. + +## 1. Problem + +The stated complaint was "design-toolkit focuses on too many `.screen` elements; I want one +feature prototype whose click covers flows, scenarios, states and variants." + +Investigation put the cause somewhere else. Screens are not over-weighted — they are the +traceability key, and they earn it. The defect is that **the same hook set is hand-written in +three unlinked places**: + +| # | File | Authored at | Holds | +|---|---|---|---| +| 1 | `traceability.md` | state 07 | `Prototype element / hook` column | +| 2 | `reference/audit-plan.json` | state 08 | `screens[]` + `states[]` — the same URLs | +| 3 | `play.html` `FEATURES[]` | state 09 | one row per page — the same pages | + +The vendored `examples/signin/` demonstrates the drift risk directly: the string +`signin.html?view=signin&state=locked` exists in both `audit-plan.json` and +`traceability-signin.md`, hand-copied, with no checker between them. + +Two consequences follow: + +- **Review is manual.** Reaching a scenario, error state or variant means reading the + traceability table, copying a hook, and pasting it into the URL bar. That is the clicking + cost. +- **Coverage is unverifiable.** Nothing proves the audit drove every hook the prototype + ships, because the two lists are independent documents. + +## 2. Decisions taken + +Three decisions were settled during brainstorming and constrain everything below. + +**D1 — Feature is the review unit; the screen stays the key.** +`sid` remains `S-XXX-nn`. `Architecture/screen-contract.md`, the `C_CONTRACT` guard, the +Figma/frontend/API/QA mapping blocks and the dev handoff are untouched. Feature is what you +browse and report on, not what anything is keyed by. + +**D2 — Adapter override only.** +`design-toolkit/**` stays byte-identical, with the single exception of `VENDORED.md`, which +is adapter-authored rather than vendored (it links to `../Architecture/*`, paths upstream +cannot know). No `.mjs` tool is edited. Every behaviour change is expressed through generated +input files and CLI flags the tools already accept. + +**D3 — Feature is a shell concept, not a file concept.** +Prototype pages stay one self-contained file per flow. Rule B1's record is explicit: whole-file +writes crashed and lost work, and a flow file already runs 1,300–2,000 lines. A merged feature +page would be the sum of its flows — 4,000+ lines — and would reintroduce exactly that failure. + +## 3. The featuremap + +One generated file replaces the three hand-written lists. + +**Path:** `design/prototype//featuremap.json` — **inside the prototype directory, +deliberately.** Every Chrome-driving tool serves `toolkit.config.json` `paths.prototype` as the +web root (`serve(PROTO, PORT)` in `audit.mjs`, `smoke.mjs`, `stateprobe.mjs`), so a sibling +`design/featuremap/` directory is outside the served root and the player's `fetch` would 404. +It is registered in `toolkit.config.json` `review.harnessFiles` alongside `play.html`, +`run-local.sh` and `serve.py`, so state 08's palette sweep and state 12's network sweep exclude +it the same way they exclude the rest of the review chrome. +**Written by:** state 07 (prototype), as data, in the same edit that creates the pages. +**Read by:** state 08 (audit-plan generation), state 09 (review player), state 12 (hook scan). +**Never hand-authored.** Regenerated on every prototype change, like the `screen-registry.csv` +export in `Workflows/flow-visualization.md` §0 and the `toolkit.config.json` bridge in +`Workflows/ui-workflow.md` §0.3. + +### 3.1 Schema + +```json +{ + "brd": "BRD-004", + "generated_by": "prototype", + "reads_versions": { + "ui-plan-checkout.md": "ui-checkout-02", + "flows-checkout.md": "flows-checkout-01" + }, + "axes": [ + { "id": "base", "label": "Base", "query": "" }, + { "id": "dark", "label": "Dark", "query": "mode=dark" }, + { "id": "rm", "label": "Reduced motion", "query": "rm=1" }, + { "id": "km", "label": "Khmer", "query": "lang=km" } + ], + "features": [ + { + "id": "FEAT-checkout", + "label": "Checkout", + "pri": "P0", + "flows": [ + { + "id": "F1", + "label": "Card payment", + "page": "checkout.html", + "screens": [ + { + "sid": "S-CHK-01", + "label": "Cart review", + "view": "cart", + "states": [ + { "id": "happy", "query": "view=cart" }, + { "id": "empty", "query": "view=cart&state=empty" }, + { "id": "error", "qualifier": "network", "query": "view=cart&state=neterr" } + ], + "axes": ["base", "dark"], + "axes_reason": "no motion on this screen; all copy is numerals — rm and km add no coverage" + } + ] + } + ] + } + ] +} +``` + +### 3.2 Field rules + +- **`state.id` must be a member of the closed vocabulary** defined once at + `design-toolkit/tools/config.mjs` `CANON_STATES`: `happy`, `loading`, `empty`, `error`, + `fail`, `success`, `in-progress`, `timeout`, `guest`, `locked`, `confirm`, `filtered`, + `offline`, `permission-denied`. Anything narrower goes in `qualifier`, per the existing + qualifier rule that `navgraph` (`N11-state-vocab`) and `stategraph` (`S1-vocab`) already + enforce. The featuremap does not introduce a fifteenth term and does not define a second + copy of the set — the generator imports it from `config.mjs`. +- **Axes are not states.** `guest` is a canon *state*, so a "guest checkout" case is + `{"id":"guest"}` under `states`, never an axis value. Axes are presentation dimensions + only: locale, theme, motion preference, density. A generator that finds a canon state term + used as an axis id fails with a `blocking` finding rather than guessing. +- **`axes` on a screen is optional.** Absent means every axis in the top-level `axes` array + applies. Present means narrowed — and then `axes_reason` is **required**; the generator + exits non-zero without it. +- **`sid` must resolve** against the exported `screen-registry.csv` for the same BRD. An + unresolvable `sid` is a `blocking` finding, not a skipped row — the same treatment + `navgraph` gives an unparsed nav token. + +## 4. What consumes it + +### 4.1 `play.html` — the review player + +Rewritten at `Templates/prototype/play.html`. The vendored copy at +`design-toolkit/templates/prototype/play.html` is not touched. + +Today `FEATURES[]` is a hand-maintained array of pages and the stage is a bare iframe. After: +the player fetches `featuremap.json` over HTTP and renders a feature list plus four in-stage switchers — +`flow ▾ screen ▾ state ▾ axis ▾`. Selecting any of them sets the iframe `src` from the +leaf's `page` + `query` + axis query. **One click into a feature; no hooks typed by hand.** + +Unchanged in the player: it stays review chrome, stays listed in `toolkit.config.json` +`review.harnessFiles`, and is therefore still excluded from state 08's palette sweep and state +12's network sweep. Its own colours must not reappear as product findings — that regression is +on record. + +The empty-state behaviour is preserved in new terms: no featuremap, or a featuremap with no +features, renders the existing "nothing registered yet" message rather than a blank stage. A +`fetch` that fails renders the reason — opening `play.html` from `file://` cannot read the +featuremap, and the player says so rather than presenting an empty feature list, which would be +indistinguishable from a prototype that registered nothing. + +### 4.2 `audit-plan.json` — generated, not authored + +`design-toolkit/tools/audit.mjs` reads `reference/audit-plan.json` via `--plan` and does not +care who wrote it. The generator writes it. + +Two emission rules, both taken from the vendored signin example's own note: + +1. **`id` appears only on a screen's happy row in the base axis.** The `id matches registry` + assertion compares the page's printed `data-sid` to this field; a state row and a screen + row are different claims, and repeating `id` across axes would count one screen many times. + So: `state.id === "happy"` and `axis === "base"` → emit into `screens[]` with `id: sid`. + Everything else → emit into `states[]` with a label and no `id`. +2. **The axis query is baked into the row URL, and `passes` stays `[{"name":"base","query":""}]`.** + `audit.mjs` multiplies its global `passes[]` across every row, which cannot express + per-screen narrowing. Baking the axis into each row gives exact per-leaf control **and** + keeps the whole run inside a single Chrome launch. Row labels carry the axis: + `S-CHK-01 happy · dark`. + +### 4.3 State 12 + +`Workflows/flow-visualization.md` currently scans the prototype directory for deep links. +It reads the featuremap instead. Scanning infers; reading does not. The `N9` finding for a +screen-id prefix with no page survives unchanged — it is now raised against a featuremap entry +rather than a filesystem guess. + +### 4.4 `traceability.md` + +The `Prototype element / hook` column is rendered from the featuremap rather than typed. +`Templates/traceability.md` gains a line saying so. The other six tables are untouched. + +## 5. Harness runtime + +### 5.1 Measured baseline + +| Tool | Chrome | serve port | cdp port | +|---|---|---|---| +| `smoke.mjs` | yes | 8791 | 9335 | +| `stateprobe.mjs` | yes | **8791** | 9341 | +| `audit.mjs` | yes | 8797 | 9337 | +| `navgraph` `stategraph` `annotate` `linkcheck` `mermaidcheck` | no | — | — | + +A full suite is 8 sequential node boots, 3 Chrome launches and 3 `python3 -m http.server` +processes. CDP ports were deliberately spread (+2 / +4 / +8); serve ports were not, and +`smoke` and `stateprobe` collide on 8791 — evidence the suite was authored to run strictly +sequentially. + +### 5.2 `extensions/design/run-suite.mjs` + +A runner, not a tool rewrite. Three levers: + +**L1 — wave the five file-only tools.** The only ordering constraint is that `annotate` reads +`navgraph.json`. So wave 1 = `navgraph ∥ stategraph ∥ linkcheck ∥ mermaidcheck`, wave 2 = +`annotate`. Five sequential boots become two waves. + +**L2 — overlap the Chrome tools with that wave.** `audit` already binds 8797 and clears. +`stateprobe` accepts `--port`, so it takes 8799 and the 8791 clash disappears. `smoke` has no +port flag and stays pinned to 8791, running alone. This is a flag, not a code change. + +**L3 — per-leaf axis narrowing.** Section 3.2. Today every row pays every pass: the signin +example is 11 rows × 2 passes = 22 drives, including reduced-motion on screens with no motion. + +> **L1 and L2 cost no coverage. L3 does.** That is why narrowing requires `axes_reason` and why +> every dropped row is printed in the run log with the reason that dropped it. A shrunk drive +> set reporting "all green" is precisely the failure the validation engine exists to prevent, +> and a silent cap would make it indistinguishable from full coverage. + +**Exit codes are honoured as `Architecture/validation-engine.md` defines them.** Exit `2` is a +tool error — *unevaluable*, not passing. The runner aggregates per-tool exit codes and reports +the worst; a wave in which any tool exits 2 does not report success, and the runner never +substitutes its own verdict for a check that did not run. + +## 6. Context load — splitting the cloned workflows + +Both cloned workflow files already carry clean `PART A / B / C` boundaries. Split there. + +| Today | Becomes | Approx. lines | +|---|---|---| +| `Workflows/ui-workflow.md` (621) | `ui-workflow.md` — §0 execution contract + index | 65 | +| ↳ PART A, state 06 | `Workflows/ui-planning.md` | 155 | +| ↳ PART B, state 07 | `Workflows/prototype.md` | 220 | +| ↳ PART C, state 08 | `Workflows/self-audit.md` | 180 | +| `Workflows/design-review.md` (608) | `design-review.md` — contract + index | 30 | +| ↳ PART A, state 09 | `Workflows/user-review.md` | 95 | +| ↳ PART B, state 10 | `Workflows/revision.md` | 225 | +| ↳ PART C, state 11 | `Workflows/final-output.md` | 155 | + +A stage loads roughly 200 lines where it previously loaded 621. + +**This is re-filing, not summarizing.** Every line moves; none is dropped or condensed. The +distinction is load-bearing: summarizing the vendored skills is the exact defect v1.9.0 was +written to repair, and a split that quietly compressed anything would recreate it. The +`Cloned from: @ 4081c24` provenance line travels with each part. + +Sixteen files cite the two original paths and are updated in the same change: +`Documentation/module-index.md`, `Documentation/CHANGELOG.md`, `Checklists/ui-review.md`, +`Checklists/design-qa.md`, `Workflows/ux-workflow.md`, `Workflows/flow-visualization.md`, +`Workflows/ui-workflow.md`, `Workflows/project-onboarding.md`, `Playbooks/full-feature.md`, +`Playbooks/design-only.md`, `Architecture/workflow-state-machine.md`, +`Architecture/validation-engine.md`, `Architecture/design-state-machine.md`, +`Templates/traceability.md`, `Templates/prototype/README.md`, `Skills/ui-designer.md`. + +## 7. Vendor bookkeeping + +`design-toolkit/VENDORED.md` gains **Override 3 — review unit**, in the idiom of the two +overrides it already carries: the vendored method's review surface is one page per flow +registered by hand; here the review unit is the feature, driven by a generated featuremap, and +the prototype pages remain per-flow beneath it. + +The same edit fixes a pre-existing inaccuracy found while reading it: the header claims the +copy is "verbatim except `.git/` and `.github/`", but `design-toolkit/.github/` is present. +Either the directory goes or the claim does — the claim is corrected to match what is on disk, +since deleting it would make the copy diverge from source for no gain. + +## 8. Out of scope + +Explicitly unchanged, and any diff touching these is a defect in this work: + +- `design-toolkit/**` apart from `VENDORED.md` +- all ten `.mjs` tools +- `Architecture/screen-contract.md`, the `SCR-` key, and the `C_CONTRACT` guard +- Dev Planning, QA, Tech Review, security certification, and every stage downstream of the + Design Gate +- the closed state vocabulary — no new terms +- `AI/model-routing.md` tiers + +## 9. Verification + +Evidence required before this is called done. Presence of a file is not evidence that it +carries the right content. + +**Unit tests** — `extensions/design/featuremap.test.mjs` and `run-suite.test.mjs`, using +`node:test` + `node:assert/strict` with no dependencies, matching `extensions/telegram/`. +Run with `node --test extensions/design/*.test.mjs`. Cases: + +1. A featuremap with a `state.id` outside `CANON_STATES` produces a `blocking` finding. +2. A canon state term used as an axis id produces a `blocking` finding. +3. A screen carrying `axes` without `axes_reason` exits non-zero. +4. Narrowed axes print every dropped row and its reason to the run log. +5. Generated `audit-plan.json` puts `id` on base-axis happy rows only, and no other row. +6. Generated `audit-plan.json` bakes axis queries into row URLs and leaves `passes` as the + single base entry. +7. A `sid` absent from the exported registry produces a `blocking` finding, not a skip. +8. `run-suite` reports the worst exit code across a wave, and treats `2` as unevaluable rather + than as a pass. +9. The featuremap is written inside `paths.prototype` and appears in `review.harnessFiles`, so + the palette and network sweeps do not report it as product surface. + +**End-to-end** — regenerate the vendored `examples/signin/` set through the new path *into a +scratch directory, never into the vendored tree*, and assert the generated `audit-plan.json` +drives the same 11 screen/state rows the hand-authored one drives. Equal coverage from a +generated plan is the proof that the second hand-written list was redundant. + +**Repository invariants** — after the split, the link check reports 0 broken links across the +toolkit's markdown, and `git diff --stat design-toolkit/` shows `VENDORED.md` and nothing else. + +## 10. Open question deferred, not answered + +The four uncommitted versions (v1.7.0 → v1.10.0) and the two CHANGELOG claim defects found +before this work — the absolute "0 vendored artifact paths outside `design-toolkit/`" wording, +which greps to five deliberate remap notes — are a separate change. They are recorded here so +they are not lost, and are not folded into this one. diff --git a/Playbooks/design-only.md b/Playbooks/design-only.md index 6918747..7470973 100644 --- a/Playbooks/design-only.md +++ b/Playbooks/design-only.md @@ -9,14 +9,15 @@ | 1 | `Analysis` | [business-analysis](../Workflows/business-analysis.md) | [analysis checklist](../Checklists/analysis.md) + Clarification Gate | | 2 | `Planning` | [product-planning](../Workflows/product-planning.md) | **Direction Gate** | | 3 | `Design` | ux → ui workflows (states 04–08 full rigor) | ux-review, ui-review, [design-qa](../Checklists/design-qa.md) `pass` | -| 4 | `Design Review` | Run-local presentation, limitations transparent | **Design Gate** = the terminal gate here | +| 4 | `Design Review` | [design-review](../Workflows/design-review.md) — Run Local, hook table shipped, limitations transparent, freeze hashes recorded | **Design Gate** | +| 4b | `Design Review` | [flow-visualization](../Workflows/flow-visualization.md) — **set `design.handoff_required: true` for this playbook**: the whole point is a deliverable someone else builds from later | [flow-visualization](../Checklists/flow-visualization.md) → **Developer Handoff Gate** = the terminal gate here | | — | `Released` (design-terminal) | Package per below; freeze; sweep | see Close-out | -Design machine state 11 (`FINAL_OUTPUT`) runs as the close-out: approval current, prototype frozen, completeness vs design-relevant ACs. +Design machine state 11 (`FINAL_OUTPUT`) runs as the close-out — [design-review](../Workflows/design-review.md) Part C: approval current **and naming its bytes**, prototype frozen by hash, completeness checked against the traceability matrix, known limitations shipped inside the deliverable at full strength. ## Close-out (replaces build stages) -1. Assemble [design-handoff](../Templates/design-handoff.md) — even with no immediate builder: it's the future build BRD's entry ticket. +1. Assemble [design-handoff](../Templates/design-handoff.md) — even with no immediate builder: it's the future build BRD's entry ticket. Include the navigation map + `navmap-report.md`; a build team that was not in the room reads the map first. 2. Freeze prototype commit; record hash in S08. 3. S15 = design-deliverable notes: what was validated, what the Design Gate approved, open Extension Notes' fates. 4. Deferred-items sweep (unbuilt = everything): S16 terminal entry lists the build-BRD seed(s) explicitly with scope pointers. @@ -25,7 +26,8 @@ Design machine state 11 (`FINAL_OUTPUT`) runs as the close-out: approval current ## Resuming into build later - New BRD (or reopen, logged S16) referencing the design BRD; entry stage = `Dev Planning`. -- **Staleness check first** (mandatory): DS evolved? Requirements drifted? Prototype still runs against current DS? Deltas → route through the design machine's REVISION triage before planning — stale approved designs are the classic trap; the Design Gate approval was scoped to what the user saw *then*. +- **Staleness check first** (mandatory): DS evolved? Requirements drifted? Prototype still runs against current DS? Deltas → route through the design machine's REVISION triage before planning — stale approved designs are the classic trap; the Design Gate approval was scoped to the bytes the user saw *then*, recorded by sha256. +- **Re-derive the navigation map** before planning: a boundary is a dated claim, and the edge-set diff is the only honest answer to "is this still current?". ## Rules diff --git a/Playbooks/full-feature.md b/Playbooks/full-feature.md index db70962..063dd48 100644 --- a/Playbooks/full-feature.md +++ b/Playbooks/full-feature.md @@ -11,9 +11,11 @@ | 1 | `Analysis` | [business-analysis](../Workflows/business-analysis.md) — S01–S06 | [analysis](../Checklists/analysis.md) ✚ Clarification Gate if blocking | | 2 | `Planning` | [product-planning](../Workflows/product-planning.md) — prioritize, recommend | **Direction Gate** | | 3 | `Design` | [ux-workflow](../Workflows/ux-workflow.md) → [ui-workflow](../Workflows/ui-workflow.md) (design states 04–08) | [ux-review](../Checklists/ux-review.md), [ui-review](../Checklists/ui-review.md), [design-qa](../Checklists/design-qa.md) `pass` | -| 4 | `Design Review` | Serve prototype (run-local), present package + limitations | **Design Gate** | +| 4 | `Design Review` | [design-review](../Workflows/design-review.md) — Run Local, hook table as the packet, limitations at full strength; REVISION routing on changes | **Design Gate** | +| 4b | `Design Review` *(only if `design.handoff_required`)* | [flow-visualization](../Workflows/flow-visualization.md) — derive the navigation map from the Screen Contract, validate, present the report | [flow-visualization](../Checklists/flow-visualization.md) → **Developer Handoff Gate** | | 5 | `Dev Planning` | [frontend-planning](../Workflows/frontend-planning.md) ∥ [backend-planning](../Workflows/backend-planning.md) (backend only if server scope) | [development-ready](../Checklists/development-ready.md) | -| 6 | `Implementation` | [implementation](../Workflows/implementation.md), slice by slice | implementation criteria | +| 6 | `Implementation` | [implementation](../Workflows/implementation.md), slice by slice — each S06 mitigation lands in the slice that creates its surface | implementation criteria | +| 6b | `Implementation` | [security-certification](../Workflows/security-certification.md) — freeze the sha, run the evidence, verify every mitigation at `file:line` | [security](../Checklists/security.md) → **`C_SECURITY`** (machine gate, no human token) | | 7 | `QA` | [qa](../Workflows/qa.md) — evidence per AC, S09 walk | [qa-testing](../Checklists/qa-testing.md) | | 8 | `Tech Review` | [code-review](../Workflows/code-review.md) — 7 dimensions | [code-review](../Checklists/code-review.md) | | 9 | `PR` | [git](../Workflows/git.md) — PR from [template](../Templates/pull-request.md), CI green | CI ✅ | @@ -25,7 +27,9 @@ - Design Review changes → REVISION triage → root-cause design state → design-qa → Design Review (`L_DESIGN` ≤3) - QA blockers → Implementation (`L_QA` ≤3) · Review changes → Implementation (`L_REVIEW` ≤2) · Final changes → Implementation → QA → Tech Review → PR (`L_HUMAN` ≤3) +- `not-certified` → Implementation, counted against `L_QA`. Any loop that pushes commits re-issues the certificate against the new head (delta re-verification; full pass when the delta touches `high_risk_scopes`). - Any ceiling → `Blocked` + escalation summary. Never silent continuation. +- Step 4b is skipped by default (`design.handoff_required: false`) and the skip is logged S16 — a fourth gate is never imposed silently. Turn it on when the builder is not the designer. ## Session pattern (solo reality) @@ -38,3 +42,5 @@ - No stage skipped, no gate pre-granted, however small the feature feels — small features use small *content*, not fewer *gates* ([hotfix](hotfix.md) is the sanctioned reduced path for genuine emergencies). - Findings land in the BRD at discovery time, every stage — the living-doc invariant. - Stage inputs missing → back-transition, never improvise (orchestrator anti-rules). +- Bound-frame deviations on arrangement or hierarchy are **findings + Product Owner rulings before landing**, never "composition choices" (`M6` extended to layout; screen-contract §4a). Lane briefs say so. +- A Product Owner handoff that authorises implementation without the Design Gate **does not remove design review**: the fidelity review (`M7`) runs before device proof and is logged S16. diff --git a/Playbooks/hotfix.md b/Playbooks/hotfix.md index b8da458..a658d24 100644 --- a/Playbooks/hotfix.md +++ b/Playbooks/hotfix.md @@ -13,7 +13,7 @@ Live regression/incident: users blocked, data at risk, or revenue path down. Not 3. **Branch** `fix/-` from main. 4. **Fix + regression test** — fails-before/passes-after proven. Fix stays minimal: no refactors, no bystander cleanup (S16-note them). 5. **Compressed QA** (not skipped): regression test + suite green + smoke the broken flow + its S09 recovery neighbors + quick boundary check around the change. Evidence in S13 — three rows beat zero rows. -6. **Compressed review**: correctness + security + plan-conformance dimensions minimum (the three that page you at night). S14 verdict. +6. **Compressed certification + review**: certification is **not** compressible on the fix diff — a hotfix touches production directly. Run [security-certification](../Workflows/security-certification.md) scoped to the fix (`C_SECURITY` stands); then the compressed review: correctness + certificate-currency + plan-conformance (the three that page you at night). S14 verdict. 7. **PR + Final Gate** — [pull-request template](../Templates/pull-request.md) with incident context; **Final Gate stands** (one message; the gate protects against tired-panic-you). CI green. 8. **Merge, deploy, verify live** — smoke the previously-broken flow on production. Watch logs through the smoke window. 9. **Close honest**: S15 hotfix note, root-cause S16 entry, and **mandatory follow-up sweep**: prevention candidates (missing test layer? checklist gap? standard gap?) → toolkit change or seed BRD. Every hotfix feeds prevention or it will repeat. @@ -25,6 +25,7 @@ Live regression/incident: users blocked, data at risk, or revenue path down. Not | Design stages (skipped — no design surface) | Repro before fix | | Full S09 walk (→ affected neighborhood) | Regression test | | 7-dimension review (→ 3 core) | Final Gate | +| Certification **scope** (→ the fix diff) | Certification **itself** (`C_SECURITY`) | | Analysis depth (S01 symptom-level) | Live verification after deploy | | | Root-cause honesty (S16) | diff --git a/README.md b/README.md index d55e14b..246fead 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A reusable AI product-development operating system for solo builders working with Claude, Notion, and Git. It turns feature development into a deterministic 13-stage state machine with living documents, hard quality gates, and full resumability — any session can die at any moment and the next one picks up exactly where it left off. -**Version: v1.4.1** · consumed by projects as a version-pinned git submodule · improve a rule once here, every project inherits it at its next pin bump. +**Version: v1.10.0** · consumed by projects as a version-pinned git submodule · improve a rule once here, every project inherits it at its next pin bump. --- @@ -10,8 +10,9 @@ A reusable AI product-development operating system for solo builders working wit ``` ┌─────────────────────────────┐ - │ YOU (three gates) │ - │ direction · design · final │ + │ YOU (3 gates + 1 optional) │ + │ direction · design · final │ + │ [· developer handoff] │ └──────▲───────────────▲───────┘ terminal│ │Telegram plugin │ │(extensions/, opt-in) @@ -23,23 +24,26 @@ A reusable AI product-development operating system for solo builders working wit └───────┬───────────────────────┬───────────────────────┬─────────────────────┘ │ reads config │ reads/writes state │ loads process ┌───────▼────────────┐ ┌───────▼────────────┐ ┌───────▼─────────────────────┐ -│ PROJECT MANIFEST │ │ LIVING BRD │ │ Workflows/ (15 stages) │ -│ project repo │ │ Notion, per feature│ │ Skills/ (16 roles) │ -│ │ │ properties = state │ │ Standards/ Templates/ │ +│ PROJECT MANIFEST │ │ LIVING BRD │ │ Workflows/ (18 modules) │ +│ + RESOURCE REGISTRY│ │ Notion, per feature│ │ Skills/ (16 roles) │ +│ project repo │ │ properties = state │ │ Standards/ Templates/ │ │ SCREEN CONTRACT │ │ S01–S16 = content │ │ Checklists/ Prompts/ │ │ project repo │ │ │ │ Playbooks/ │ └────────────────────┘ └─────────▲──────────┘ └─────────────────────────────┘ - │ MCP + │ MCP (registry-scoped) External services: Notion (MCP) · GitHub (gh) · - browser (prototype review) · your project repo + browser (prototype review) · your project repo(s) + — only resources BOUND in the registry, never the + whole workspace (Project Boundary Rule) ``` -The orchestrator is the only component that advances state. Everything it needs to resume lives in the three sources of truth — never in the session. Full wiring: [Architecture/integration-map.md](Architecture/integration-map.md). +The orchestrator is the only component that advances state. Everything it needs to resume lives in the three sources of truth — never in the session. External access is scoped to the Project Resource Registry — the toolkit never searches your workspace once a project is onboarded. Full wiring: [Architecture/integration-map.md](Architecture/integration-map.md). ## Design Philosophy - **Single source of truth** — every fact has exactly one home (manifest, BRD, or Screen Contract). Workflows consume, never re-ask, never duplicate. -- **AI executes, human directs** — Claude runs all 13 stages; you decide at three gates. See [Decision Boundaries](#decision-boundaries). +- **Explicit resource ownership** — every project **binds** its external resources (Notion DBs, Figma files, repos, docs, chats) at onboarding, by stable identifier. The registry is a hard boundary: the toolkit touches only what's bound, and asks connect-or-create when something's missing — it never searches your workspace, never guesses. ([Architecture/project-manifest.md](Architecture/project-manifest.md) §3) +- **AI executes, human directs** — Claude runs all 13 stages; you decide at three gates, plus a fourth (**Developer Handoff**) only if you switch it on. See [Decision Boundaries](#decision-boundaries). - **Stage-based development** — features move through a gated state machine with checklisted exits, not freeform prompting. - **Resume from state, not session** — machine position is reconstructed from Notion properties + the S16 Decision Log. Kill any session, nothing is lost. - **Modular & versioned** — single-responsibility modules, semver-tagged, consumed as a pinned submodule. Projects upgrade deliberately. @@ -53,7 +57,7 @@ The orchestrator is the only component that advances state. Everything it needs | Artifact | Truth for | Lives in | |----------|-----------|----------| -| `project-manifest.yaml` | project configuration (stack, design, integrations, git) | project repo | +| `project-manifest.yaml` | project configuration + **Resource Registry** (every bound external resource, by stable ID) | project repo | | **Living BRD** (one Notion page per feature) | requirements, decisions, progress, history | Notion database | | **Screen Contract** (`screens/`) | design → development traceability per screen | project repo | @@ -63,11 +67,12 @@ Workflows consume these; they never re-ask what an artifact already answers. Fea ``` Ready → Analysis → Planning → Design → Design Review → Dev Planning → -Implementation → QA → Tech Review → PR → Human Review → Merged → Released - 🚦 Direction Gate 🚦 Design Gate 🚦 Final Gate +Implementation →🔒→ QA → Tech Review → PR → Human Review → Merged → Released + 🚦 Direction Gate 🚦 Design Gate 🚦 Final Gate + 🔒 Security Certificate (machine gate) ``` -Claude runs every stage; **you only act at the three gates** (approve scope → review the running prototype → approve the PR). Everything else — research with citations, edge-case enumeration, prototype, code, evidence-based QA, 7-dimension review — executes and logs itself to the BRD. Loops are bounded (no infinite revision), approvals go stale if content changes after them, and nothing ships with unmet acceptance criteria. +Claude runs every stage; **you only act at the three gates** (approve scope → review the running prototype → approve the PR) — plus the optional **Developer Handoff Gate** on the navigation map, off by default (`design.handoff_required`). Everything else — research with citations, edge-case enumeration, prototype, code, **a security certificate issued against a named commit before QA opens**, evidence-based QA, 7-dimension review — executes and logs itself to the BRD. Loops are bounded (no infinite revision), approvals go stale if content changes after them, and nothing ships with unmet acceptance criteria. **3. State lives in Notion, never in the session.** Every transition writes the BRD's Decision Log (S16) and its status properties. Kill any session; the next one reconstructs everything from Notion alone. @@ -79,7 +84,8 @@ What actually happens when you say "pick up the ready BRD" — every stage, ever User request ↓ Workflow Orchestrator (AI/orchestrator.md) - ↓ manifest gate: load + validate project-manifest.yaml + ↓ manifest gate: load + validate project-manifest.yaml (incl. bound Resource Registry) + ↓ boundary: all external access resolves through the registry — nothing outside it ↓ load Living BRD — properties give exact machine position ↓ check Screen Contract (blocks Dev Planning if mappings incomplete) ↓ load the stage's Workflow + Skill + referenced Standards, at routed model tier @@ -101,17 +107,19 @@ The orchestrator loads only what the current stage needs; nothing global sits in Layer 1 · CORE ENGINE orchestrator, state machines, model routing fires transitions, enforces gates — the only writer of machine state ↓ -Layer 2 · PROJECT CONFIGURATION project-manifest.yaml, project-overrides.md - tells the engine what this project is; validated before any work +Layer 2 · PROJECT CONFIGURATION project-manifest.yaml (config + Resource + Registry), project-overrides.md — tells the engine what this project + is and which external resources it OWNS; validated before any work ↓ Layer 3 · DEVELOPMENT SYSTEM Living BRD, Screen Contract, Workflows, Skills, Standards — what each stage does and to what quality bar ↓ -Layer 4 · COMMUNICATION extensions/ — Telegram v1 (Slack/Discord +Layer 4 · COMMUNICATION extensions/ — Telegram v2 (Slack/Discord possible later); adapters that relay gates, never advance state ↓ Layer 5 · EXTERNAL SERVICES Notion (MCP), GitHub (gh), browser - where state, code, and review surfaces physically live + where state, code, and review surfaces physically live — reached + only through Layer-2 registry bindings (Project Boundary Rule) ``` Lower layers never depend on higher ones; extensions and services are replaceable without touching the engine. @@ -121,25 +129,29 @@ Lower layers never depend on higher ones; extensions and services are replaceabl ## One-Time Setup (once ever, ~15 min) 1. **Requirements:** Claude Code (or Claude with MCP), Node 18+, `gh` CLI authenticated, Notion MCP connected. -2. **Create the BRD database** — one database for all projects. Say to Claude: +2. **Create the BRD database + Toolkit Registry** — one database for all projects. Say to Claude: ```text - Create the BRD database per toolkit Documentation/notion-setup.md. + Create the BRD database per toolkit Documentation/notion-setup.md, + then write the Toolkit Registry (§3b). ``` - (Exact properties, views, and page scaffold: [Documentation/notion-setup.md](Documentation/notion-setup.md).) + The registry (`~/.toolkit/registry.yaml`) records the DB's identity so no project ever searches for it. (Exact properties, views, scaffold: [Documentation/notion-setup.md](Documentation/notion-setup.md); registry contract: [Architecture/toolkit-registry.md](Architecture/toolkit-registry.md).) 3. **Optional — Telegram gate approvals on your phone:** create a bot via @BotFather, `export TELEGRAM_BOT_TOKEN=…` in your shell profile. Wiring happens per-project at onboarding. ([extensions/telegram/README.md](extensions/telegram/README.md)) --- ## Start a Project -### Step 0 — Register (repo optional at this point) +### Step 0 — Register (optional here; repo not needed yet) + +In any session that has the toolkit on disk (your Dev-toolkit clone works): ```text Register a new project per toolkit project-onboarding step 0: -name , code , , stage , stack . +name , code , "", , +stage , stack . ``` -Identity + BRD-ID code reserved in Notion. A registered project with no repo yet is a valid resting state. +Identity + BRD-ID code reserved in Notion (via the Toolkit Registry). A registered project with no repo yet is a valid resting state. Skipping this is fine — onboarding (Step 2) runs registration as its own step 0. ### Step 1 — Repo + pin @@ -149,8 +161,8 @@ npx create-next-app@latest --typescript --tailwind --eslint --app --sr cd git submodule add https://github.com/Chheng64/Dev-toolkit.git toolkit -cd toolkit && git fetch --tags && git checkout v1.4.1 && cd .. -git add -A && git commit -m "chore: pin toolkit v1.4.1" +cd toolkit && git fetch --tags && git checkout v1.10.0 && cd .. +git add -A && git commit -m "chore: pin toolkit v1.10.0" ``` ### Step 2 — Onboard (mandatory; nothing runs without it) @@ -159,7 +171,7 @@ git add -A && git commit -m "chore: pin toolkit v1.4.1" Run toolkit/Workflows/project-onboarding.md for this project. ``` -~5 minutes: detects your stack (asks only what it can't detect) → validates every integration and URL → seeds known screens with stable Screen IDs → generates `project-manifest.yaml` + `context/` (AI session-bootstrap summaries) → asks about Telegram **once** (yes / no / later — "later" is never re-asked) → commits. Then push and protect `main` (PR-only + CI). +~5–10 minutes: detects your stack (asks only what it can't detect) → **Project Resource Binding**: for every supported resource — Notion BRD DB (required, inherited from the Toolkit Registry) + project page (+ optional sprint / decision-log DBs), Figma design file + DS library, GitHub repos (frontend/backend — at least one required — + optional infra), API/architecture/product docs, Telegram (asked **once**: yes / no / later — "later" is never re-asked), and any other MCP-backed resources — you **connect an existing** one, have the toolkit **create a new** one, or **skip** (optional slots; recorded, never re-asked); stable IDs (never display names) land in the Resource Registry → validates every integration and binding (`✓ Notion BRD Database accessible`, `✓ GitHub repository accessible`, …) → generates `project-manifest.yaml` → seeds known screens with stable Screen IDs → generates `context/` (AI session-bootstrap summaries) → **pushes, scaffolds CI if absent, protects `main` (PR-only + required CI), commits, and stamps complete**. From then on the toolkit touches **only registry resources** — it never searches your workspace. ### Step 3 — First BRD @@ -185,6 +197,7 @@ The machine runs. You'll be stopped at the Direction Gate first: a summary of th | Something's broken | `Run toolkit/Workflows/debug.md on: ` | | Production emergency | `Run the hotfix playbook: ` | | Project config changed | `toolkit onboard --update` (targeted re-onboarding) | +| New/moved external resource (DB, Figma file, repo, doc) | `toolkit onboard --update` — rebind that slot (connect existing / create new) | | Enable/change Telegram | `toolkit configure communication` | Up to **3 BRDs run in parallel** (one branch + one PR each); the orchestrator serializes anything whose planned code areas overlap. @@ -199,8 +212,11 @@ Register → Onboard → Create BRD (Ready) Analysis ──🚦 Direction Gate (approve scope) ↓ Planning → Design → Design Review ──🚦 Design Gate (review running prototype) + └─🚦 Developer Handoff Gate (navigation map — only if enabled) + ↓ +Dev Planning → Implementation ──🔒 Security Certificate (C_SECURITY, no human token) ↓ -Dev Planning → Implementation → QA → Tech Review → PR +QA → Tech Review → PR ↓ Human Review ──🚦 Final Gate (approve PR) ↓ @@ -219,7 +235,9 @@ Approvals are scoped to what you saw — if gated content changes afterward, the - Silent deviation from plan is forbidden — deviate and log, or route back. - Reviews cover 7 dimensions (correctness, standards, security, performance, accessibility, DS conformance, plan conformance) on every diff. - Screens carry stable **SCR-IDs** from onboarding through QA (`C_CONTRACT` blocks implementation on incomplete mappings). +- External access stays inside the **Project Resource Registry** — a missing resource stops the stage with a connect-or-create ask; workspace searching and guessing are defects. - Approvals are scoped to what you saw — content changes revoke them automatically. +- **Security is a precondition, not a late review dimension**: `C_SECURITY` blocks QA until S14 carries a `certified` Security Certificate naming the current branch head — evidence with exit codes, every S06 threat mitigation verified at `file:line`, waivers carrying riders. New commits stale it; it re-verifies the delta. ([Workflows/security-certification.md](Workflows/security-certification.md)) - Model routing: heavyweight reasoning (Opus-tier) only where wrong judgment cascades — Planning, UX, Dev Planning, Review; mechanical work rides cheap tiers. ([AI/model-routing.md](AI/model-routing.md)) ## Decision Boundaries @@ -227,7 +245,8 @@ Approvals are scoped to what you saw — if gated content changes afterward, the | Human decides | AI executes | |---------------|-------------| | Business direction & scope (Direction Gate) | Analysis, research with citations | -| Design approval (Design Gate) | Planning, UX, prototype, implementation | +| Design approval (Design Gate) | Planning, UX, prototype, self-audit against the render, implementation | +| Navigation-map sign-off (Developer Handoff Gate — only when `design.handoff_required`) | Deriving the map from the Screen Contract, validating it, reporting findings | | Merge approval (Final Gate) | QA with evidence, 7-dimension review, documentation | If a decision isn't at a gate, it's Claude's — bounded by the permission matrix and logged in S16. ([Architecture/permission-matrix.md](Architecture/permission-matrix.md)) @@ -240,7 +259,7 @@ Dependencies point **down toward `Architecture/`** — it depends on nothing; `P | Dir | Purpose | Depends on | Edit when | Claude loads | |-----|---------|-----------|-----------|--------------| -| [Architecture/](Architecture/) | Contracts: BRD schema (S01–S16), permission matrix, state machines, manifest, screen contract, stack profiles, versioning | nothing (foundation) | contract changes — semver-gated | always (via orchestrator) | +| [Architecture/](Architecture/) | Contracts: BRD schema (S01–S16), permission matrix, state machines, manifest + Resource Registry, toolkit registry, screen contract, stack profiles, versioning | nothing (foundation) | contract changes — semver-gated | always (via orchestrator) | | [AI/](AI/) | Runtime: orchestrator, entry contract, BRD update protocol, model routing, MCP setup | Architecture | runtime behavior changes | every session entry | | [Workflows/](Workflows/) | 15 stage procedures (analysis → release, debug, onboarding) | Architecture | a stage's procedure improves | per stage | | [Skills/](Skills/) | 16 roles with decision boundaries | Architecture | a role's judgment improves | per stage | @@ -249,7 +268,7 @@ Dependencies point **down toward `Architecture/`** — it depends on nothing; `P | [Checklists/](Checklists/) | 12 machine-checkable gates | Architecture | an exit criterion changes | at stage exit | | [Prompts/](Prompts/) | 11 invocation patterns | Architecture | an invocation improves | when you invoke one | | [Playbooks/](Playbooks/) | full-feature · parallel-brds · hotfix · design-only | everything (composition layer) | a composed flow changes | when a playbook is invoked | -| [extensions/](extensions/telegram/README.md) | Opt-in adapters (Telegram v1) | orchestrator contract only | adding/changing an adapter | only if enabled in manifest | +| [extensions/](extensions/telegram/README.md) | Opt-in adapters (Telegram v2) | orchestrator contract only | adding/changing an adapter | only if enabled in manifest | | [Documentation/](Documentation/) | [Module index](Documentation/module-index.md) · [Onboarding](Documentation/onboarding.md) · [Notion setup](Documentation/notion-setup.md) · [Changelog](Documentation/CHANGELOG.md) | — | docs drift | on demand | New functionality goes where its single responsibility says: process → `Workflows/`, judgment → `Skills/`, quality bar → `Standards/`, contract → `Architecture/`, adapter → `extensions/`. diff --git a/Skills/design-system-engineer.md b/Skills/design-system-engineer.md index db71fef..b7f64cc 100644 --- a/Skills/design-system-engineer.md +++ b/Skills/design-system-engineer.md @@ -1,7 +1,7 @@ # Skill — Design System Engineer > **Module:** Skills -> **Used by:** [Workflows/design-system-workflow.md](../Workflows/design-system-workflow.md); supports ui-workflow on DS questions +> **Used by:** [Workflows/design-system-workflow.md](../Workflows/design-system-workflow.md); supports ui-workflow on DS questions (design system named by **source id** before any plan validates against it) > **Matrix row:** Design System Engineer — [permission-matrix.md](../Architecture/permission-matrix.md) (authoritative) ## Role diff --git a/Skills/security-reviewer.md b/Skills/security-reviewer.md index 88dd9c4..59dc518 100644 --- a/Skills/security-reviewer.md +++ b/Skills/security-reviewer.md @@ -1,7 +1,7 @@ # Skill — Security Reviewer > **Module:** Skills -> **Used by:** [Workflows/code-review.md](../Workflows/code-review.md) (security dimension); backend-planning consultation; standalone security-review BRDs +> **Used by:** [Workflows/security-certification.md](../Workflows/security-certification.md) (**owns** `C_SECURITY` on the Implementation → QA edge); [Workflows/code-review.md](../Workflows/code-review.md) (dimension 4 — verifies the certificate is current); backend-planning consultation (threat model into S06); standalone security-review BRDs > **Matrix row:** Security Reviewer — [permission-matrix.md](../Architecture/permission-matrix.md) (authoritative) ## Role @@ -14,12 +14,14 @@ Thinks like the caller you didn't intend: what can be reached, with what forged/ - Planning consultation: threat pass over backend contracts before they freeze - Verify S06 security mitigations exist in code — mitigation claimed ≠ mitigation present - Rate/abuse posture on exposed endpoints +- **Issue the Security Certificate** before QA: freeze the sha, run the configured evidence, verify every S06 mitigation at `file:line`, classify, verdict — [template](../Templates/security-certificate.md) +- Keep the certificate current: branch head moves → delta re-verification and re-issue; auth/payment/PII/data-export deltas → full pass ## Decision Boundaries -- **Decides:** finding validity and severity; whether a mitigation satisfies its S06 entry. +- **Decides:** finding validity and severity; whether a mitigation satisfies its S06 entry; the certification verdict (`certified` / `not-certified`) and the scope it covers. - **Escalates:** risk-acceptance calls (user only, in writing in S06/S16), auth-model ambiguity (identity vs profile semantics → `Affects: S10`), findings requiring product change (`Affects: S07`/`S03`). -- **Never:** accepts "internal only" as an authz argument; lets raw provider errors reach clients; signs off on secrets in code/logs; downgrades an auth bypass below `blocker`; expands into offensive tooling — verification stays defensive. +- **Never:** accepts "internal only" as an authz argument; lets raw provider errors reach clients; signs off on secrets in code/logs; downgrades an auth bypass below `blocker`; reports unconfirmed scanner output as a finding; certifies a branch rather than a commit; treats an unavailable scanner as a pass; fixes the code it certifies; expands into offensive tooling — verification stays defensive. ## BRD Sections @@ -29,6 +31,10 @@ Append S10, S06, S05, S14; append S16. Findings with attack path + impact + concrete fix, severity honest (`blocker` = auth bypass, injection, secret exposure, data-integrity risk). Verified mitigation matrix against S06. +## Certification boundary + +Reviewer files, implementer fixes, reviewer re-verifies — the same boundary QA holds. A certifier who patches the code loses the only independent read of it. + ## Handoff → **Code Reviewer** (dimension results into S14 verdict). diff --git a/Skills/ui-designer.md b/Skills/ui-designer.md index cdd87fb..d8d5fbd 100644 --- a/Skills/ui-designer.md +++ b/Skills/ui-designer.md @@ -1,7 +1,7 @@ # Skill — UI Designer > **Module:** Skills -> **Used by:** [Workflows/ui-workflow.md](../Workflows/ui-workflow.md) (design states 06–08); REVISION targets for visual/component changes +> **Used by:** [Workflows/ui-workflow.md](../Workflows/ui-workflow.md) (design states 06–08) · [Workflows/design-review.md](../Workflows/design-review.md) (presents at states 09–11) · [Workflows/flow-visualization.md](../Workflows/flow-visualization.md) (state 12, when handoff is in scope); REVISION targets for visual/component changes > **Matrix row:** UI Designer — [permission-matrix.md](../Architecture/permission-matrix.md) (authoritative) ## Role @@ -15,13 +15,15 @@ Turns flows into concrete, design-system-conformant interfaces and a running pro - Define layout/hierarchy rules per state; tokens by reference - Assemble the prototype: all states (incl. non-happy), all transitions (incl. recovery), served via run-local - Maintain the traceability map (element → spec → DS asset) -- Run self-audit (state 08): conformance, a11y, reduced-motion, AC evidence, verdict +- Run self-audit (state 08) **rendering-class**: computed visibility + geometry, screenshots read across language × theme × motion × state, source swept, AC evidence, verdict scoped to the bytes audited +- Ship a deep-link hook per state/variant/error case — the hook table is the review packet +- Own the navigation map (state 12) when `handoff_required`: derive it from the Screen Contract, never draw it ## Decision Boundaries - **Decides:** component decomposition, layout, DS asset selection, visual hierarchy within DS constraints. - **Escalates:** DS gaps (Extension Note → Design System Engineer), missing/ambiguous flow states (back to UX, never invent), spec insufficiency (back-transition). -- **Never:** invents ad-hoc hex/spacing/type where a DS entry exists; adds un-specced elements; forks feature-local styling; skips the a11y audit; self-certifies `pass` with open blockers. +- **Never:** invents ad-hoc hex/spacing/type where a DS entry exists; adds un-specced elements; forks feature-local styling; skips the a11y audit; self-certifies `pass` with open blockers; reports an unconfirmed probe failure as a finding; hand-patches a derived connector. ## BRD Sections @@ -34,5 +36,5 @@ S08 + prototype + traceability meeting ui-workflow completion criteria; S14 audi ## Handoff → **User (Design Gate)** via orchestrator with the running prototype URL + known limitations. -→ **Frontend Engineer** (Dev Planning) after approval: traceability map + DS mapping precise enough that implementation is assembly, not interpretation. +→ **Frontend Engineer** (Dev Planning) after approval: traceability map + DS mapping precise enough that implementation is assembly, not interpretation; plus the navigation map + report when the Developer Handoff Gate ran. → **Design System Engineer** for open Extension Notes. diff --git a/Skills/ux-designer.md b/Skills/ux-designer.md index 39a001a..de62637 100644 --- a/Skills/ux-designer.md +++ b/Skills/ux-designer.md @@ -16,6 +16,8 @@ Owns what the user experiences structurally: tasks, IA, states, transitions, rec - Build flows: triggers, guards, mutually exhaustive branches, recovery routes, no dead ends - Define accessibility + reduced-motion strategy at feature level - Preserve user intent across interruptions (auth boundaries, async waits, redirects) +- Carry unruled guards as open decisions (`o-`) instead of defaulting a branch +- Ratify (or refuse) routes that design state 12's derivation reports as unratified — flows are ruled here, never in the design file ## Decision Boundaries diff --git a/Standards/security.md b/Standards/security.md index dd49928..701b6a7 100644 --- a/Standards/security.md +++ b/Standards/security.md @@ -1,7 +1,7 @@ # Standard — Security > **Module:** Standards · Deviations: none for the floor; risk acceptance is a user-signed S06/S16 decision, never a silent skip. -> **Applies to:** all code; verified at review dimension 4 + [../Checklists/security.md](../Checklists/security.md). Defensive scope only. +> **Applies to:** all code. Enforced **before QA** by `C_SECURITY` — [security-certification](../Workflows/security-certification.md) issues a [Security Certificate](../Templates/security-certificate.md) against a named commit, validated by [../Checklists/security.md](../Checklists/security.md); re-checked at review dimension 4. Defensive scope only. ## Rules diff --git a/Templates/design-mapping.md b/Templates/design-mapping.md index 586879d..ba4adfe 100644 --- a/Templates/design-mapping.md +++ b/Templates/design-mapping.md @@ -11,6 +11,9 @@ ## Design - **Figma frame:** - **Figma components used:** +- **Reading order:** +- **Chrome / presentation:** · safe-area> +- **Alignment:** - **DS components:** + + + + + + + + + + + + +
+
+ + +
+

Sign in

+

Use the email and password for your account.

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

Reset password

+

We will email you a link to set a new one.

+ + + +
+ + +

We send the same reply whether or not the address has an account.

+
+ + +
+ + + +
+

Check your email

+

If that address has an account, a reset link is on its way to

+ sam@example.com + +
+

Sent again. Give it a minute before checking.

+
+ + + + +
+ +
+
+ + + + diff --git a/design-toolkit/examples/signin/artifacts/flow-visualization-signin.md b/design-toolkit/examples/signin/artifacts/flow-visualization-signin.md new file mode 100644 index 0000000..d4a235c --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/flow-visualization-signin.md @@ -0,0 +1,113 @@ +--- +artifact: flow-visualization +version: navmap-signin-01 +produced_by: flow-visualization +reads_versions: + screen-registry.csv: "3 rows · S-SIGN-01, S-SIGN-02, S-SIGN-03" + flows-signin.md: flow-signin-01 + prototype: proto-signin-03 + state-machines.json: "3 machines · 11 states · 17 transitions" + edge-annotations.json: "4 edges · 3 frames" +scope: "signin only — S-SIGN-01, S-SIGN-02, S-SIGN-03. This map covers one flow and makes no claim about any other." +figma: { file: null, pages: [] } +gate: { developer_handoff: granted, date: "2026-08-07", granted_by: user } +--- + +# Navigation Map — signin + +**Status: READY FOR DEVELOPMENT**, scoped to the flows named in `scope` — never a bare "handoff ready". + +## What was produced, and what was not + +The **derivations** are the product of this state: `navgraph.json`, `stategraph.json`, `stateprobe.json` and `annotations.json`, each with a report and an exit code. **No Figma layer was generated.** That is recorded as a scope statement, not omitted: a design file is a *rendering* of `navgraph.json`, and in a Design file a connector is a vector that does not reflow — a stale arrow is indistinguishable from a fresh one. The derivations are what a build team can re-check; the picture is what would go stale. + +## Sections built + +| Section | Journey | Screens | Edges | Figma node | +|---|---|---|---|---| +| `FLOW-001 • Sign In and Password Reset` | credential entry → recovery → reset request → acknowledgement | 3 | 4 | — (no design file this run) | + +One Section, because there is one journey. A Section named after a *feature* becomes a bucket, and a bucket answers no question. + +## Findings at gate + +| Severity | Code | Subject | Status | +|---|---|---|---| +| — | — | — | **none outstanding** | + +Four validators, four clean exits. The gate passed on **this report and these exit codes**, not on a picture. + +``` +navgraph 3 screens · 4 edges · 0 cross-feature + 0 blocking / 0 major / 0 advisory exit 0 +stategraph 3 machines · 11 states · 17 transitions · 11 hooked + 0 blocking / 0 major / 0 advisory exit 0 +stateprobe 11 urls · 11 painted · 0 failed · 0 id-drift exit 0 +annotate 4 edges · 3 frames · 0 network call sites + 0 blocking / 0 major / 0 advisory exit 0 +``` + +### Findings that were raised and closed during this state + +Recorded because a report showing only the final state hides the work. + +| Code | What it caught | Resolution | +|---|---|---| +| `S7-dead-end` (advisory) | `locked{rate-limited}` had no outbound transition and was not marked terminal. | **Correct finding.** The way out of that state leaves the screen — the reset route goes to `S-SIGN-02`, which is `navgraph`'s edge to draw, not this machine's. Marked `terminal: true` with the reason written down. Flagged rather than left to re-report every run: a suppressed advisory teaches a reader to skim. | +| `E11-api-claim` (**blocking**) | 4 edges claimed `none (simulated)` while the sweep found a network call site at `signin.html:262`. | **Confirmed at source: the "call site" was the comment claiming there were no network calls.** The sweep is deliberately broad and matched `XMLHttpRequest` in prose. Fixed in the prototype — the disclaimer no longer names the APIs it disclaims. **A rule fell out of it: do not write the name of the thing you are claiming not to use, inside the file being swept for it.** | + +The `E11` fix moved bytes after the Primary gate, which reverted it to `pending`, which is the stale-approval rule doing its job. The delta was classified **bug-fix-only** with byte-level evidence and ratified by a scope confirm. See `review-signin-01`. + +## Boundary status (W8) + +| Port | Owning flow | Status | Checked on | +|---|---|---|---| +| `⟂HOME-01` | the authenticated area — not in this run's scope | **mocked** — the absence of a destination | 2026-08-07 | + +**A boundary is a dated claim.** `⟂HOME-01` is correct as a mock today and becomes silently wrong the moment the authenticated area ships. Re-derive this table whenever any other feature reaches `FINAL_OUTPUT`. A status with no date is not a status. + +## Sync record (W7) + +| Date | Trigger | Edge-set delta | Actions taken | +|---|---|---|---| +| 2026-08-07 | first derivation | 4 edges, from 0 | Derived from the registry, reconciled against `flow-signin-01`, annotated, probed. | +| 2026-08-07 | `proto-signin-02` → `proto-signin-03` | **0** — no edge changed | Re-ran all four validators. The prototype delta was a comment; the navigation model was unaffected, and *the derivation proving that* is the sync record. | + +*"We updated the file"* is not a sync record. A diff of the edge set is. + +## Extensions + +| Ext | Status | Evidence | +|---|---|---| +| **E1** swimlanes | **3 / 3 screens laned** — all `customer`; `admin`, `system`, `api` declared and empty | `reference/nav-lanes.json`. The **honest degenerate case** for a single-actor product: three empty lanes say *checked, nothing here*; a missing file says nothing at all. `navgraph` reports `N8` for any unlaned screen and never guesses a lane. | +| **E2** cross-feature map | **0 cross-feature edges** | `navgraph.json.crossFlow`. Every edge is intra-flow because this run is one flow. Expect ~half the edge set to cross a boundary once a second flow exists — that is the half no single flow document owns. | +| **E3** heatmap | derived, **measured not assigned** | `navgraph.json.heat`. `S-SIGN-01` is the hub: **in-degree 2** from 1 source flow. On a one-flow product a heatmap is nearly trivial; it earns its place at the scale where hubs are not obvious by eye. | +| **E4** deep links | **1 / 1 flows addressable · 11 / 11 states** | Hooks read **out of the implementation**, not out of a doc claiming them. `?view` and `?state` both confirmed read by the page. `stateprobe` then drove all 11 and asserted each paints — because proving a hook is *read* is not proving the state is *shown*. | +| **E5** state machines | **3 / 3 screens · 11 states · 17 transitions · 0 findings** | Node set derived from the registry; edge set authored with `file:line` evidence, every citation resolved against the frozen bytes. **0 states declared-but-unbuilt**, 1 terminal, 0 entry-only. | +| **E6** annotations | **4 / 4 edges · 3 / 3 frames · 0 blank · 0 UNKNOWN** | `nav` and `guard` authored with evidence; `anim` and `api` derived and re-swept every run. Every `api` value reads `none (simulated)` — **and that column is only worth reading because the tool re-greps for request APIs itself and fails if the claim and the sweep disagree. It did exactly that.** | +| **E7** overview page | **not built** | No design file this run. The counts, coverage, heatmap, QA paths, findings and legend all exist in the reports above; the **provenance block** is this document's frontmatter. | + +## What a first derivation found here, and why that is not the general case + +`navgraph` opened at **0 blocking / 0 major / 0 advisory**. That is unusual and worth stating plainly rather than presenting as typical: this registry is **3 rows, authored in one sitting directly from a ratified flow graph, never hand-edited**. The extraction run's first derivation opened at **2 blocking · 8 major · 52 advisory across 48 screens**, and every finding was a real registry defect. + +Finding count scales with registry size and with how many hands have edited it. **A clean first derivation on 3 rows is evidence about the registry's age, not about the tool's usefulness.** The layer that found real problems here was `stategraph`/`annotate` — the two that check authored claims against the frozen bytes. + +## Developer Handoff Gate + +| Requirement | Held by | +|---|---| +| navigation visualization complete (V1, V2, V6, V7) | every registry entry in the Section; every derived path annotated; entry screen marked; `FLOW-001 • Sign In and Password Reset` | +| screen contract synchronized (V13) | `navgraph.json` re-derives identically from the current registry — verified by re-running after the prototype delta | +| connectors validated (V4, V8) | derived, not drawn; direction matches the derived edge direction by construction | +| Sections organized (V3, V5) | 0 orphans; every branch terminates or is a declared boundary | +| no broken navigation | all four validators **exit 0** at `--fail-on major` | +| flow diagrams up to date (W7) | sync record above, dated, with the edge-set delta | + +**Granted** by the user, 2026-08-07, on the report and the exit codes. + +**Versions the gate saw:** registry `reference/screen-registry.csv` (3 rows) · derivation `navgraph.json`, this run · prototype `proto-signin-03`. A gate that cannot name its bytes is the defect one real gate record shipped — it dropped `reads_versions` entirely, which is the exact field delivery is checked against. + +## Exit + +Developer Handoff Gate `granted` → **`FINAL_OUTPUT`**. diff --git a/design-toolkit/examples/signin/artifacts/flows-signin.md b/design-toolkit/examples/signin/artifacts/flows-signin.md new file mode 100644 index 0000000..429e023 --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/flows-signin.md @@ -0,0 +1,160 @@ +--- +artifact: flows +version: flow-signin-01 +produced_by: flow-generation +reads_versions: + ux-plan-signin.md: ux-signin-01 + requirements-signin.md: req-signin-01 +feature: signin +folds_in: [] +--- + +# Flows — signin + +Node ids = prototype views. `⟂` = flow boundary (another flow's screen, mocked). + +Node contents below are **triggers, guards and routes**. Naming a node after a screen id is the toolkit convention; it does not make this a screen document. + +## Canon entry paths + +These are the paths a reviewer checks the prototype against. + +1. **Straight in** — cold start → credentials entered → submit → authenticated area. +2. **Mistyped and corrected** — cold start → submit → rejected → correct the secret → submit → authenticated area. +3. **Locked out, then reset** — cold start → submit rejected ×5 → locked → reset route → request → acknowledged. +4. **Forgot from the start** — cold start → reset route → request → acknowledged → back to credentials. + +## F1 — Credential entry and recovery (TASK-A, TASK-B) + +``` +app launch ──▶ S-SIGN-01 (happy) + +S-SIGN-01 ──submit──▶ [D1 both fields non-empty?] + D1 no ──▶ (unreachable — submit is inert; guarded, not branched) + D1 yes ──▶ S-SIGN-01 (loading) ──▶ [D2 reachable?] + +D2 no ──▶ S-SIGN-01 (offline) recovery: retry edge → loading +D2 yes ──▶ [D3 credentials accepted?] + + D3 yes ──▶ ⟂HOME-01 + D3 no, attempts < N ──▶ S-SIGN-01 (error{invalid-credentials}) + D3 no, attempts >= N ──▶ S-SIGN-01 (locked{rate-limited}) + +S-SIGN-01 (error{invalid-credentials}) ──correct + submit──▶ S-SIGN-01 (loading) +S-SIGN-01 (offline) ──retry──▶ S-SIGN-01 (loading) +S-SIGN-01 (locked{rate-limited}) ──reset route──▶ S-SIGN-02 (happy) +S-SIGN-01 (any state) ──reset route──▶ S-SIGN-02 (happy) +``` + +**D1:** `identifier.length > 0 && secret.length > 0` — enforced as a **guard on the affordance**, not as a branch. The false arm is unreachable by construction, which is why it is not drawn as a route. Stating it here rather than omitting it is the difference between a guarded edge and a missing one. + +**D2:** `navigator.onLine` at submit time. Exhaustive over `{true, false}`. + +**D3:** `accepted ∈ {true, false}` × `attempts ∈ {< N, ≥ N}`. Three arms, and the pair is exhaustive: accepted-and-locked cannot occur because a successful attempt resets the counter. `N` is `o-s1`, assumed 5 — **carried, not chosen here**. The branch exists regardless of the number; only the copy depends on it. + +**Not drawn, and why:** `interrupted` from `ux-signin-01` is not a node. Leaving and re-entering the area produces a fresh `S-SIGN-01 (happy)` — it is the *absence* of retained state, not a state of its own. Recorded so its absence reads as a decision. + +## F2 — Reset request (TASK-C) + +``` +S-SIGN-01 (any) ──reset route──▶ S-SIGN-02 (happy) [identifier carried in] + +S-SIGN-02 ──submit──▶ [D4 identifier well-formed?] + D4 no ──▶ S-SIGN-02 (error{invalid-email}) recovery: correct + submit → loading + D4 yes ──▶ S-SIGN-02 (loading) ──▶ [D5 reachable?] + +D5 no ──▶ S-SIGN-02 (error{invalid-email}, offline variant) + recovery: retry edge → loading +D5 yes ──▶ S-SIGN-03 (happy) ← REPLACE, not push + +S-SIGN-02 ──back──▶ S-SIGN-01 (happy) [identifier preserved] + +S-SIGN-03 ──resend──▶ [D6 within throttle window?] + D6 yes ──▶ S-SIGN-03 (error{resend-throttled}) recovery: wait, then resend + D6 no ──▶ S-SIGN-03 (success{resent}) + +S-SIGN-03 ──back to sign in──▶ S-SIGN-01 (happy) +``` + +**D4:** `wellFormed(identifier)` — exhaustive over `{true, false}`. **There is deliberately no `account exists?` decision anywhere in F2.** A well-formed identifier always reaches `S-SIGN-03`, whether or not it belongs to an account. Adding that branch would satisfy a helpfulness instinct and build an account-enumeration oracle (AC4.3, research T2). + +**D5:** `navigator.onLine`. Exhaustive. + +**D6:** `now - lastSend < window`. Exhaustive over `{true, false}`. `window` is copy, not a branch. + +`S-SIGN-02 → S-SIGN-03` is a **replace**, not a push: back into a request already submitted invites a duplicate. That is why `S-SIGN-03` carries its own explicit route back rather than relying on browser back. + +## Decision log + +| ID | Decision | Ruled by | Date | +|---|---|---|---| +| D-s4 | Password paste is not blocked — blocking it removes the mechanism SC 3.3.8 relies on | product-review (D-s4) | 2026-08-07 | +| D-f1 | `D1` is a **guard on the affordance**, not a branch. The empty-submit arm is unreachable by construction and is documented rather than drawn | this state | 2026-08-07 | +| D-f2 | **No `account exists?` decision in F2.** A well-formed identifier always reaches acknowledgement | requirements AC4.3 + research T2 | 2026-08-07 | +| D-f3 | `S-SIGN-02 → S-SIGN-03` is a **replace**; `S-SIGN-01 → S-SIGN-02` is a **push** | ux-signin-01 navigation model | 2026-08-07 | +| D-f4 | `interrupted` is not a node — it is the absence of retained state | this state | 2026-08-07 | +| D-f5 | Attempt threshold `N` is carried as `o-s1`, not chosen here. The branch is drawn; the number is not | this state | 2026-08-07 | + +## Reachability report + +| Node | Reachable from | Terminal? | Justification if terminal | +|---|---|---|---| +| `S-SIGN-01 (happy)` | app launch, S-SIGN-02 back, S-SIGN-03 back to sign in | no | — | +| `S-SIGN-01 (loading)` | S-SIGN-01 happy, error, offline retry | no | — | +| `S-SIGN-01 (error{invalid-credentials})` | D3 no, attempts < N | no | recovery: correct + submit | +| `S-SIGN-01 (locked{rate-limited})` | D3 no, attempts ≥ N | no | recovery: reset route | +| `S-SIGN-01 (offline)` | D2 no | no | recovery: retry | +| `S-SIGN-02 (happy)` | S-SIGN-01 reset route (from any state) | no | — | +| `S-SIGN-02 (loading)` | D4 yes | no | — | +| `S-SIGN-02 (error{invalid-email})` | D4 no, D5 no | no | recovery: correct + submit / retry | +| `S-SIGN-03 (happy)` | D5 yes | no | routes: resend, back to sign in | +| `S-SIGN-03 (success{resent})` | D6 no | no | routes: resend again, back to sign in | +| `S-SIGN-03 (error{resend-throttled})` | D6 yes | no | recovery: wait, then resend | +| `⟂HOME-01` | D3 yes | **boundary** | leaves this flow; owned by the authenticated area | + +**Unreachable nodes: 0.** **Dead ends without justification: 0.** + +Every non-happy node has at least one outbound edge that leads somewhere the user can act (AC6.2). None is terminal. + +## Recovery coverage + +| Non-happy state (ux-plan) | Recovery transition | +|---|---| +| TASK-A / loading | resolves to one of `⟂HOME-01`, `error{invalid-credentials}`, `locked{rate-limited}`, `offline` — never hangs | +| TASK-A / offline | retry edge → `S-SIGN-01 (loading)` | +| TASK-A / interrupted | re-entry produces `S-SIGN-01 (happy)` — see D-f4 | +| TASK-B / error (credential rejected) | correct + submit → `S-SIGN-01 (loading)` | +| TASK-B / locked (attempts exhausted) | reset route → `S-SIGN-02 (happy)` — the strongest reason to want the reset branch | +| TASK-B / offline | retry edge → `S-SIGN-01 (loading)` | +| TASK-C / loading | resolves to `S-SIGN-03 (happy)` or `S-SIGN-02 (error{invalid-email})` | +| TASK-C / error (malformed) | correct + submit → `S-SIGN-02 (loading)` | +| TASK-C / error (repeat throttled) | wait, then resend → `S-SIGN-03 (success{resent})` | +| TASK-C / offline | retry edge → `S-SIGN-02 (loading)` | + +All 10 non-happy states from `ux-signin-01` have a recovery transition (V3). + +## Flow boundaries + +| Boundary node | Owning flow | Status | Checked on | +|---|---|---|---| +| `⟂HOME-01` | authenticated area (not in this run's scope) | **mocked** | 2026-08-07 | + +**A boundary status is a dated claim.** `⟂HOME-01` is correct as a mock *today*, and becomes silently wrong the moment the authenticated area ships. Re-check this table whenever any other feature reaches `FINAL_OUTPUT`. + +**Scope of this clearance claim:** *this flow* has exactly one boundary mock, and it is the one above. The claim is about `flows-signin.md`, not about any other flow. + +## Open decisions + +- **`o-s1`:** attempt threshold `N` — the `D3` branch exists; the number does not. Ruled by: auth service owner. +- **`o-s2`:** reveal-the-secret affordance — would add no node and no edge, only an in-state control. Ruled by: product, at STATE 06. +- **`o-s3`:** copy ownership. Ruled by: content design. +- **`o-s5`:** announcement-versus-focus ordering on `error{invalid-credentials}`. Not a branch — a within-state behaviour. Ruled by: STATE 06. Verified by: STATE 08. + +## Validation self-check + +- **V1** ✅ — 0 unreachable nodes; every node's inbound source is named in the reachability report. +- **V2** ✅ — 0 dead ends without justification. The only terminal is `⟂HOME-01`, which is a boundary, and it is declared as one. +- **V3** ✅ — all 10 non-happy states from `ux-signin-01` map to a recovery transition. +- **V4** ✅ — D1–D6 each state a guard and cover its whole domain, including the `D1` arm that is unreachable by construction and the `account exists?` branch that is **deliberately absent** rather than forgotten. + +**Exit:** validation passes → `UI_PLANNING`. diff --git a/design-toolkit/examples/signin/artifacts/navgraph.json b/design-toolkit/examples/signin/artifacts/navgraph.json new file mode 100644 index 0000000..4ba29a6 --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/navgraph.json @@ -0,0 +1,204 @@ +{ + "meta": { + "root": ".", + "registry": "reference/screen-registry.csv", + "screens": 3, + "edges": 4, + "crossFlowEdges": 0, + "flows": [ + "01 Sign In" + ], + "entryPoints": [ + "S-SIGN-01" + ], + "lanesFile": true + }, + "nodes": [ + { + "id": "S-SIGN-01", + "name": "Sign in", + "flow": "01 Sign In", + "prefix": "SIGN", + "status": "designed", + "states": [ + "happy", + "loading", + "error{invalid-credentials}", + "locked{rate-limited}", + "offline" + ], + "lane": "customer", + "purpose": "Credential entry and in-place recovery from a rejected attempt", + "out": [ + "S-SIGN-02" + ], + "in": [ + "S-SIGN-02", + "S-SIGN-03" + ] + }, + { + "id": "S-SIGN-02", + "name": "Reset password", + "flow": "01 Sign In", + "prefix": "SIGN", + "status": "designed", + "states": [ + "happy", + "loading", + "error{invalid-email}" + ], + "lane": "customer", + "purpose": "Request a reset link for an identifier", + "out": [ + "S-SIGN-01", + "S-SIGN-03" + ], + "in": [ + "S-SIGN-01" + ] + }, + { + "id": "S-SIGN-03", + "name": "Check your email", + "flow": "01 Sign In", + "prefix": "SIGN", + "status": "designed", + "states": [ + "happy", + "success{resent}", + "error{resend-throttled}" + ], + "lane": "customer", + "purpose": "Acknowledge the request and offer a throttled repeat", + "out": [ + "S-SIGN-01" + ], + "in": [ + "S-SIGN-02" + ] + } + ], + "edges": [ + { + "from": "S-SIGN-01", + "to": "S-SIGN-02", + "label": "forgot password", + "source": "navigates_to", + "labels": [ + "forgot password" + ], + "crossFlow": false + }, + { + "from": "S-SIGN-02", + "to": "S-SIGN-01", + "label": "back", + "source": "navigates_to", + "labels": [ + "back" + ], + "crossFlow": false + }, + { + "from": "S-SIGN-02", + "to": "S-SIGN-03", + "label": "request accepted", + "source": "navigates_to", + "labels": [ + "request accepted" + ], + "crossFlow": false + }, + { + "from": "S-SIGN-03", + "to": "S-SIGN-01", + "label": "back to sign in", + "source": "navigates_to", + "labels": [ + "back to sign in" + ], + "crossFlow": false + } + ], + "crossFlow": [], + "heat": [ + { + "id": "S-SIGN-01", + "name": "Sign in", + "flow": "01 Sign In", + "inDegree": 2, + "outDegree": 1, + "sourceFlows": 1 + }, + { + "id": "S-SIGN-02", + "name": "Reset password", + "flow": "01 Sign In", + "inDegree": 1, + "outDegree": 2, + "sourceFlows": 1 + }, + { + "id": "S-SIGN-03", + "name": "Check your email", + "flow": "01 Sign In", + "inDegree": 1, + "outDegree": 1, + "sourceFlows": 1 + } + ], + "states": { + "perScreen": [ + { + "id": "S-SIGN-01", + "states": [ + "happy", + "loading", + "error{invalid-credentials}", + "locked{rate-limited}", + "offline" + ], + "count": 5 + }, + { + "id": "S-SIGN-02", + "states": [ + "happy", + "loading", + "error{invalid-email}" + ], + "count": 3 + }, + { + "id": "S-SIGN-03", + "states": [ + "happy", + "success{resent}", + "error{resend-throttled}" + ], + "count": 3 + } + ], + "histogram": { + "happy": 3, + "loading": 2, + "error{invalid-credentials}": 1, + "locked{rate-limited}": 1, + "offline": 1, + "error{invalid-email}": 1, + "success{resent}": 1, + "error{resend-throttled}": 1 + } + }, + "deepLinks": { + "SIGN": { + "page": "signin.html", + "params": [ + "state", + "view" + ] + } + }, + "findings": [] +} \ No newline at end of file diff --git a/design-toolkit/examples/signin/artifacts/navmap-report.md b/design-toolkit/examples/signin/artifacts/navmap-report.md new file mode 100644 index 0000000..263da12 --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/navmap-report.md @@ -0,0 +1,50 @@ +# Navigation Graph Report + +_Derived from `reference/screen-registry.csv` by `tools/navgraph.mjs`. Every edge below +traces to a registry cell — this file is evidence, not authoring._ + +| Metric | Value | +|---|---| +| Screens | 3 | +| Flows | 1 | +| Navigation edges | 4 | +| Cross-feature edges | 0 | +| Entry points | S-SIGN-01 | +| Blocking findings | 0 | +| Major findings | 0 | +| Advisory findings | 0 | + +## Findings + +None. + +## Navigation heatmap (E3) + +| Screen | Name | Flow | In | Out | Source flows | +|---|---|---|---|---|---| +| `S-SIGN-01` | Sign in | 01 Sign In | **2** | 1 | 1 | +| `S-SIGN-02` | Reset password | 01 Sign In | **1** | 2 | 1 | +| `S-SIGN-03` | Check your email | 01 Sign In | **1** | 1 | 1 | + +## Cross-feature edges (E2) + +None. + +## State inventory (E5) + +| State | Screens | +|---|---| +| happy | 3 | +| loading | 2 | +| error{invalid-credentials} | 1 | +| locked{rate-limited} | 1 | +| offline | 1 | +| error{invalid-email} | 1 | +| success{resent} | 1 | +| error{resend-throttled} | 1 | + +## Deep-link hooks (E4) + +| Flow prefix | Page | Query hooks | +|---|---|---| +| SIGN | `signin.html` | `?state` `?view` | diff --git a/design-toolkit/examples/signin/artifacts/product-review-signin.md b/design-toolkit/examples/signin/artifacts/product-review-signin.md new file mode 100644 index 0000000..5c65d67 --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/product-review-signin.md @@ -0,0 +1,97 @@ +--- +artifact: product-review +version: pr-signin-01 +produced_by: product-review +reads_versions: + requirements-signin.md: req-signin-01 + research-signin.md: res-signin-01 +feature: signin +recommendation: proceed +gate: direction-approval +gate_state: approved +gate_granted_by: user +gate_date: "2026-08-07" +gate_rulings: + o-s4: "hold 44px — audit.tapTargetFloorPx stays 44" +--- + +# Product Review — signin + +## Recommendation + +**proceed** — with one scope cut and one contradiction going to the gate unresolved. + +Three findings drove it: + +1. **The evidence supports every `must` item, and none of them is speculative.** Autofill declaration (T1), non-enumerating errors (T2) and preserve-and-refocus recovery (T3) are all cited, all cheap, and all in the same three screens. There is no requirement here whose value rests on an assumption. +2. **The one genuinely contested number is the product's own, not an external one.** C1 — the 44px floor against WCAG 2.2 AA's 24px — is a decision this product already made and wrote into `toolkit.config.json`. It is presented at the gate as unresolved because it is a **product** ruling, and STATE 08 will enforce whichever answer the gate gives. +3. **The cut is real and it removes the only high-effort item.** R5's resend throttle wants a countdown primitive the design system does not have (`o-s2`'s neighbour). Cutting the live countdown to a static message keeps the requirement and removes the extension. + +The scope is three screens and eleven flow states. Nothing below was introduced here; every id resolves in `req-signin-01`. + +## Prioritized requirements + +| ID | Requirement | Value | Effort | Risk | Band | Evidence | +|---|---|---|---|---|---|---| +| R1 | Credential entry is correct on a phone | H | L | L | **must** | [T1, T5] | +| R2 | A failed sign-in is recoverable in place | H | L | L | **must** | [T2, T3] | +| R3 | Work in flight is visible and cannot be double-submitted | M | L | L | **must** | [T3] | +| R4 | A forgotten password has a self-service route | H | M | M | **must** | [T2] | +| R6 | Every non-happy path is reachable and leads somewhere | H | M | L | **must** | [T3, T6] | +| R7 | The accessibility floor is met, not approximated | H | M | **M** | **must** | [T4, T5] | +| R5 | The reset confirmation is actionable, not a dead end | M | M | L | **should** | [T2, T6] | +| — | *(no `could` items)* | | | | | | +| R5.2 | Live countdown on the resend throttle | L | **H** | M | **cut** | [T6] — see cut list | + +Seven of seven requirements retained. One **sub-clause** cut, named explicitly rather than folded silently into its parent. + +## Risk register + +| ID | Risk | Sev | Mitigation **or** accept-risk | Owner | +|---|---|---|---|---| +| K1 | The 44px floor is stricter than the AA standard the team may believe it is conforming to. An element can pass an external audit and fail this one. | **high** | Present C1 at this gate for an explicit ruling. Whichever way it goes, the number lives in exactly one place — `audit.tapTargetFloorPx` — and STATE 08 enforces it mechanically. | product · this gate | +| K2 | The reset confirmation could be made to differ by account existence during build, reintroducing an enumeration oracle. | **high** | AC4.3 is a falsifiable criterion, and the single confirmation screen is the design that makes the failure hard to build. STATE 08 marks it met or unmet with evidence. | design + build | +| K3 | The lockout threshold (A3, `o-s1`) is an assumed number that will appear in shipped copy. | med | Carried as an open decision, not defaulted at build time. It occupies one string; changing it is a copy change, not a design change. | auth service owner | +| K4 | Every string in this flow is placeholder and unreviewed (`o-s3`). | med | **ACCEPTED** — this is a reference run, not a shipping product. Accepted by: the person granting this gate. It ships inside the deliverable's Known limitations at full strength. | product | +| K5 | The prototype simulates all responses (A2), so nothing here proves the real service behaves as designed. | med | **ACCEPTED** — explicit non-goal. STATE 08's source sweep confirms no network call exists, so the claim "simulated" is measured rather than asserted. Accepted by: the person granting this gate. | product | + +Both `high`-severity risks carry a mitigation. Both `med` acceptances name who accepts them. + +## Scope contradictions + +- **X1 — the target-size floor.** `req-signin-01` AC7.1 requires **44 × 44** hit area. Research theme **T4** shows WCAG 2.2 **AA** requires **24 × 24** (S7) and that 44 × 44 is the **AAA** criterion (S8). Both are correct; they answer different questions. This is a product ruling, not a research finding, and it **goes to the gate unresolved**. Recorded as **`o-s4`**. + + The consequence of each answer, stated so the ruling is informed: + - **Hold 44px** — the design system's own primitives are already 48px, so nothing in this flow is at risk. The cost is that a future screen conforming to AA can still fail this audit. + - **Relax to 24px** — matches the external standard, and immediately makes the `ds-link` hit-area expansion (`::after{inset:-14px}`) unnecessary. + + **Ships as, if unruled:** 44px, because it is the reversible reading — lowering a floor later is a config change, raising one restyles approved screens. + + > **RULED at this gate, 2026-08-07 — hold 44px.** Grantor: user. `audit.tapTargetFloorPx` stays `44`. STATE 08 enforces 44, not 24, and an element conforming to WCAG 2.2 AA can still fail this audit. That is the intended behaviour, not a defect. `o-s4` is closed. + +- **X2 — announce versus refocus.** Research contradiction **C2**. `role="alert"` announces without moving focus (S5); SC 3.3.1 wants the item in error identified (S6). AC2.2 asks for focus on the password field and AC2.3 asks for the announcement. Doing both naively can make a screen reader interrupt itself. + + **Not a direction question** — the ordering is a STATE 06 decision and STATE 08 verifies it. Deferred, not resolved here, and recorded so it is not discovered during assembly. + +## Decision record + +- **D-s1: Proceed at three screens, eleven flow states.** — trigger that would reverse it: a ruling that the reset link target is in scope, which changes this from a three-screen flow to a five-screen one and re-opens this gate. +- **D-s2: The live resend countdown is cut; the throttle ships as static copy.** — trigger: the design system gaining a countdown primitive, at which point it returns as a `should` at no extra cost. +- **D-s3: The design system is `toolkit-ref-ds-0.1`, and its own assumptions were checked against this product's** — mobile, 393 × 852, light, Latin, LTR — and match. — trigger: any of those diverging. This check exists because a plan built on the wrong design system validates perfectly against it. +- **D-s4: Password paste is not blocked.** — trigger: none foreseen; blocking it would break the mechanism SC 3.3.8 relies on (T5). + +**Deferred to later states, with ids:** + +| Open | Question | Owning state | +|---|---|---| +| `o-s1` | Lockout threshold and window (assumed 5 / 15 min) | copy, at STATE 06 | +| `o-s2` | Password-visibility toggle in scope? DS has no primitive → Extension Note | STATE 06 | +| `o-s3` | Copy ownership — every string is placeholder | ships as a known limitation | +| `o-s4` | **44px versus AA's 24px** — X1 above | **this gate** | +| `o-s5` | Announce-versus-refocus ordering — X2 above | STATE 06, verified at STATE 08 | + +## Cut list + +- **R5.2 — live countdown on the resend throttle.** Out for this cycle. The design system has no countdown primitive, so building it is a new component with a justification, a timer, a reduced-motion equivalent and an announcement policy — high effort against low value on a screen the user sees once. **What brings it back:** the design system gaining the primitive, or evidence that users retry the resend before the window expires often enough to matter. + + R5 itself is retained. AC5.2 is satisfied by static copy stating the wait, which is also what T6 recommends: a control that silently stops working reads as broken; one that says why does not. diff --git a/design-toolkit/examples/signin/artifacts/prototype/play.html b/design-toolkit/examples/signin/artifacts/prototype/play.html new file mode 100644 index 0000000..949adea --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/prototype/play.html @@ -0,0 +1,175 @@ + + + + + +Prototype Player + + + + +
+ Product prototypes + Features Prototyping +
    + open standalone ↗ +
    + + + + + + + diff --git a/design-toolkit/examples/signin/artifacts/prototype/run-local.sh b/design-toolkit/examples/signin/artifacts/prototype/run-local.sh new file mode 100755 index 0000000..2a6ecfd --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/prototype/run-local.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Run Local — default USER_REVIEW step (docs/workflow.md STATE 09, skills/09 G1). +# Copied into artifacts/prototype/ by STATE 07; run it from there. +# Serves prototype/ over HTTP (serve.py — live reload: any file change in +# prototype/ auto-refreshes the browser, but ONLY while the workflow is in +# USER_REVIEW per state/machine_state.yaml) and opens the player (play.html). +# Idempotent: re-run = "Refresh run local" — reuses a live server, just reopens player. +# A plain server (no live reload) already on the port gets replaced. +set -euo pipefail + +PORT="${1:-8765}" +DIR="$(cd "$(dirname "$0")" && pwd)" +URL="http://localhost:${PORT}/play.html" + +up() { lsof -nP -iTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; } +live_reload() { curl -sf "http://localhost:${PORT}/__watch" >/dev/null 2>&1; } + +if up && live_reload; then + echo "refresh: live-reload server already on :${PORT} — reusing" +else + if up; then + echo "upgrade: plain server on :${PORT} — replacing with live-reload server" + lsof -nP -tiTCP:"${PORT}" -sTCP:LISTEN | xargs kill 2>/dev/null || true + sleep 0.3 + fi + echo "start: serving ${DIR} on :${PORT} (live reload)" + (cd "${DIR}" && nohup python3 serve.py "${PORT}" >/dev/null 2>&1 &) + # wait until up (max ~3s) + for _ in $(seq 1 30); do + curl -s -o /dev/null "${URL}" && break + sleep 0.1 + done +fi + +echo "player: ${URL}" +if command -v open >/dev/null 2>&1; then open "${URL}"; fi diff --git a/design-toolkit/examples/signin/artifacts/prototype/serve.py b/design-toolkit/examples/signin/artifacts/prototype/serve.py new file mode 100644 index 0000000..4c03a36 --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/prototype/serve.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Run Local dev server — static serve + live reload (docs/workflow.md STATE 09). + +Lives in artifacts/prototype/ (copied from templates/prototype/ by STATE 07). + +Serves this directory and injects a tiny poller into every .html response; +any file change in the tree reloads the browser automatically. +Endpoint /__watch returns the newest mtime in the tree (also used by +run-local.sh to detect a live-reload-capable server vs a plain one). + +Live reload is active ONLY while the workflow is in the USER_REVIEW state +(state/machine_state.yaml top-level current_state). Outside review the +server still serves pages, but /__watch answers "off" and no poller is +injected — checked per request, so a state transition applies without +restart. Missing/unreadable state file = reload ON (standalone use). +""" +import os +import re +import sys +from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler + +DIR = os.path.dirname(os.path.abspath(__file__)) +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 +STATE_FILE = os.environ.get('TOOLKIT_STATE_FILE') or os.path.abspath( + os.path.join(DIR, '..', '..', 'state', 'machine_state.yaml')) + + +def in_review(): + try: + with open(STATE_FILE, encoding='utf-8') as f: + for line in f: + m = re.match(r'^ current_state:\s*(\S+)', line) + if m: # first 2-space-indented hit = machine_state's own field (parked flows nest deeper) + return m.group(1) == 'USER_REVIEW' + except OSError: + pass + return True + +SNIPPET = (b"") + + +def latest_mtime(): + mx = 0 + for root, _dirs, files in os.walk(DIR): + for f in files: + try: + mx = max(mx, os.stat(os.path.join(root, f)).st_mtime_ns) + except OSError: + pass + return str(mx) + + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=DIR, **kwargs) + + def log_message(self, *args): + pass + + def _send(self, body, ctype): + self.send_response(200) + self.send_header('Content-Type', ctype) + self.send_header('Cache-Control', 'no-store') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + live = in_review() + if self.path.startswith('/__watch'): + self._send(latest_mtime().encode() if live else b'off', 'text/plain') + return + if not live: + super().do_GET() + return + path = self.translate_path(self.path.split('?', 1)[0].split('#', 1)[0]) + if os.path.isdir(path): + path = os.path.join(path, 'index.html') + if path.endswith('.html') and os.path.isfile(path): + with open(path, 'rb') as f: + body = f.read() + if b'' in body: + body = body.replace(b'', SNIPPET + b'\n', 1) + else: + body += SNIPPET + self._send(body, 'text/html; charset=utf-8') + return + super().do_GET() + + +if __name__ == '__main__': + print(f'serve.py: {DIR} on :{PORT} (live reload on)') + ThreadingHTTPServer(('', PORT), Handler).serve_forever() diff --git a/design-toolkit/examples/signin/artifacts/prototype/signin.html b/design-toolkit/examples/signin/artifacts/prototype/signin.html new file mode 100644 index 0000000..f6fb89a --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/prototype/signin.html @@ -0,0 +1,373 @@ + + + + + +Sign In — proto-signin-02 + + + + + +
    + proto-signin-02 + S-SIGN-01 + + + + + + + + + + + +
    + +
    +
    + + +
    +

    Sign in

    +

    Use the email and password for your account.

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

    Reset password

    +

    We will email you a link to set a new one.

    + + + +
    + + +

    We send the same reply whether or not the address has an account.

    +
    + + +
    + + + +
    +

    Check your email

    +

    If that address has an account, a reset link is on its way to

    + sam@example.com + +
    +

    Sent again. Give it a minute before checking.

    +
    + + + + +
    + +
    +
    + + + + diff --git a/design-toolkit/examples/signin/artifacts/requirements-signin.md b/design-toolkit/examples/signin/artifacts/requirements-signin.md new file mode 100644 index 0000000..d47bf6e --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/requirements-signin.md @@ -0,0 +1,97 @@ +--- +artifact: requirements +version: req-signin-01 +produced_by: requirement-analysis +scope_class: small +effort_tier: 1 flow · 3 screens · 11 flow states +feature: signin +--- + +## Problem statement + +A returning user on a phone has no reliable way back into the product: a mistyped +credential currently costs them everything they typed, and a forgotten password +has no self-service route at all. This feature covers credential entry, in-place +recovery from a failed attempt, and a request for a password-reset link — mobile +first, on a 393 × 852 viewport. + +## Goals + +- **G1:** A returning user with correct credentials reaches the authenticated area from a cold start on a phone. +- **G2:** A user who mistypes a credential can correct and retry **without re-entering what was already right**. +- **G3:** A user who has forgotten their password can request a reset link without contacting support. +- **G4:** The whole flow is operable one-handed at 393 × 852 and meets the product's own accessibility floor. + +## Actors + +- **Returning user** — primary. Knows they have an account; may or may not remember the password. +- **Support agent** — secondary and indirect. Every self-service reset is a ticket that does not arrive. + +## Constraints + +- Mobile-first. 393 × 852 is the review and capture viewport. +- Single locale (`en`), Latin script only, light theme only — matching the design system's own stated assumptions. +- **No live backend.** Every response in the prototype is simulated; no network call exists anywhere in it. +- **Account enumeration must not be possible from the reset flow** — the confirmation cannot differ by whether the address is registered. +- Design system: `toolkit-ref-ds-0.1`, named by source id in `toolkit.config.json`. + +## Non-goals + +- Sign-up and account creation. +- Social login, SSO, or any third-party identity provider. +- The reset **link target** — the set-a-new-password screen. Out of scope for this run. +- Biometric authentication, remembered devices, and multi-factor. +- Server-side rate-limiting policy. The design shows the user-facing state; the threshold is the server's. + +## Requirements & acceptance criteria + +- **R1: Credential entry is correct on a phone.** + - **AC1.1:** The email field carries `type="email"`, `inputmode="email"` and `autocomplete="username"`; the password field carries `type="password"` and `autocomplete="current-password"`. + - **AC1.2:** Field text renders at ≥ 16px, so mobile Safari does not zoom the viewport on focus. + - **AC1.3:** The submit control is disabled while either field is empty, and its disabled state is conveyed by more than colour alone. + +- **R2: A failed sign-in is recoverable in place.** + - **AC2.1:** After an invalid-credentials response, the entered email is **still present** in the email field. + - **AC2.2:** After an invalid-credentials response, focus moves to the password field. + - **AC2.3:** The error is announced to assistive technology (`role="alert"`) and **does not state which of the two credentials was wrong**. + +- **R3: Work in flight is visible and cannot be double-submitted.** + - **AC3.1:** While a submit is in flight the control shows a busy state, sets `aria-busy`, and a second submit is impossible. + - **AC3.2:** The busy state is reachable by a deep-link hook and paints. + +- **R4: A forgotten password has a self-service route.** + - **AC4.1:** A control on the sign-in screen reaches the reset-request screen in **one tap**. + - **AC4.2:** The reset screen pre-fills the email already typed on the sign-in screen, when one was typed. + - **AC4.3:** Submitting a reset request shows the **same** confirmation regardless of whether the address is registered. + +- **R5: The reset confirmation is actionable, not a dead end.** + - **AC5.1:** The confirmation names the address the link was sent to. + - **AC5.2:** A resend control exists, is throttled after first use, and the throttle is stated in visible copy rather than implied by a disabled control. + - **AC5.3:** A route back to sign-in exists from the confirmation. + +- **R6: Every non-happy path is reachable and leads somewhere.** + - **AC6.1:** Each of `loading`, `error{invalid-credentials}`, `locked{rate-limited}`, `offline`, `error{invalid-email}` and `error{resend-throttled}` is reachable by a deep-link hook and paints. + - **AC6.2:** Each of those states offers at least one control that leads somewhere the user can act — none is terminal. + +- **R7: The accessibility floor is met, not approximated.** + - **AC7.1:** Every interactive element measures **≥ 44 × 44 CSS px of hit area** at 393 × 852. Hit area, not box — an expanded `::after` target counts. + - **AC7.2:** Every text/background pair carrying load-bearing copy measures ≥ 4.5:1, or ≥ 3:1 at ≥ 18.66px. + - **AC7.3:** Every motion has a static equivalent under `prefers-reduced-motion`, and no state is signalled by motion alone. + - **AC7.4:** No page produces console errors, and no page scrolls horizontally at 393px. + +## Assumptions + +- **A1 [assumed]:** Single locale `en`, Latin script, LTR, light theme only. `product.scripts` is therefore empty and the per-glyph font check is off — correct here, and wrong for any product that renders a second script. +- **A2 [assumed]:** The prototype simulates every response. No `fetch`, `XMLHttpRequest`, `WebSocket`, `sendBeacon` or `EventSource` exists in it. STATE 08's source sweep is expected to confirm this rather than take it on trust. +- **A3 [assumed]:** The lockout the user sees is **5 failed attempts, 15-minute window**. The real threshold is server policy and is not this design's to set. Accepted explicitly here rather than left as a blocking question; carried forward as `o-s1` and reversible before build. +- **A4 [assumed]:** The reset **link target** is a separate flow, per non-goals. The confirmation screen is where this feature ends. +- **A5 [confirmed]:** 393 × 852 viewport and a 44px interactive floor — both set in `toolkit.config.json` (`product.viewport`, `audit.tapTargetFloorPx`) and checked by STATE 08. +- **A6 [confirmed]:** Design system `toolkit-ref-ds-0.1`, whose own assumptions (mobile, 393 × 852, light, Latin, LTR) were checked against this product's and match. + +## Open questions + +_No `blocking` question remains. Each item below is `non-blocking` and carried forward as an open decision rather than defaulted silently at build time._ + +- **Q1 [non-blocking] `o-s1`:** Exact lockout threshold and window. Assumed 5 / 15 min per **A3**. Blocks nothing; the number appears in one string. Ruled by: whoever owns the auth service. +- **Q2 [non-blocking] `o-s2`:** Is a password-visibility toggle in scope? The design system has **no primitive** for it, so adding one is an Extension Note at STATE 06, not a free addition. Ruled by: product. +- **Q3 [non-blocking] `o-s3`:** Copy ownership. Every string here is placeholder and unreviewed. Ships as a known limitation unless someone claims it. Ruled by: content design. diff --git a/design-toolkit/examples/signin/artifacts/research-signin.md b/design-toolkit/examples/signin/artifacts/research-signin.md new file mode 100644 index 0000000..c19128e --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/research-signin.md @@ -0,0 +1,95 @@ +--- +artifact: research +version: res-signin-01 +produced_by: research +reads_versions: + requirements-signin.md: req-signin-01 +coverage: 4/4 goals mapped-or-waived (100%) +feature: signin +--- + +# Research — signin + +## Themes + +- **T1: Autofill only works if the fields are declared for it.** Browser and password-manager autofill keys off `autocomplete` tokens and input types. `username` + `current-password` is the pair a sign-in form is expected to expose; getting it wrong silently disables the single largest reduction in typing on a phone. + - sources: [S1, S2] + - relevance: AC1.1, AC1.2 — and, indirectly, G2, because the cheapest way not to lose what the user typed is not to make them type it + - maps_to: [G1, G2] + +- **T2: An authentication error must not identify which credential was wrong.** Distinguishing "no such account" from "wrong password" turns the sign-in form into an account-enumeration oracle. The same rule drives the reset flow: the response must be identical whether or not the address is registered. + - sources: [S3, S4] + - relevance: AC2.3, AC4.3 + - maps_to: [G2, G3] + +- **T3: Error recovery is a focus-management problem, not a copy problem.** An error that is visible but not announced is invisible to a screen-reader user; an error that clears the form costs the user everything they typed. The pattern is: preserve input, move focus to the field to correct, announce the message in a live region. + - sources: [S5, S6] + - relevance: AC2.1, AC2.2, AC2.3 + - maps_to: [G2, G4] + +- **T4: The 44px target floor is a product decision above the external standard, not a restatement of it.** WCAG 2.2 SC 2.5.8 sets a **24 × 24** minimum; SC 2.5.5 sets **44 × 44** at AAA. This product's own acceptance criterion is 44px, which is stricter than the AA conformance target — so an element can conform to AA and still fail this product's audit. + - sources: [S7, S8] + - relevance: AC7.1 + - maps_to: [G4] + +- **T5: Requiring the user to reproduce a memorised secret is itself an accessibility concern.** WCAG 2.2 SC 3.3.8 requires that a cognitive function test not be the only way through authentication unless an alternative exists — and it names password-manager paste support as a qualifying mechanism. Blocking paste on a password field breaks it. + - sources: [S9] + - relevance: AC1.1, AC7.2, and the decision **not** to block paste + - maps_to: [G1, G4] + +- **T6: Rate-limiting is server policy; the design owns only the state the user sees.** Guidance is consistent that throttling belongs at the service, and that the user-facing message should not leak how close the account is to a limit. + - sources: [S4, S10] + - relevance: AC6.1 `locked{rate-limited}`, and assumption A3 + - maps_to: [G2] + +## Evidence & citations + +- **S1 [resolvable]:** The `autocomplete` attribute's token list, including `username`, `current-password` and `new-password`, and the behaviour user agents key off — MDN, *HTML attribute: autocomplete*: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete +- **S2 [resolvable]:** `inputmode` and input-type behaviour on touch keyboards — MDN, *inputmode*: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inputmode +- **S3 [resolvable]:** Authentication responses must be generic and must not permit username enumeration — OWASP *Authentication Cheat Sheet*: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html +- **S4 [resolvable]:** The reset flow must return an identical response regardless of account existence, and reset requests should be throttled — OWASP *Forgot Password Cheat Sheet*: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html +- **S5 [resolvable]:** `role="alert"` is an assertive live region; content added to it is announced without moving focus — WAI-ARIA Authoring Practices, *Alert pattern*: https://www.w3.org/WAI/ARIA/apg/patterns/alert/ +- **S6 [resolvable]:** SC 3.3.1 Error Identification — the item in error must be identified and described in text — WCAG 2.2: https://www.w3.org/TR/WCAG22/#error-identification +- **S7 [resolvable]:** SC 2.5.8 Target Size (Minimum), AA, 24 × 24 CSS px — WCAG 2.2: https://www.w3.org/TR/WCAG22/#target-size-minimum +- **S8 [resolvable]:** SC 2.5.5 Target Size (Enhanced), AAA, 44 × 44 CSS px — WCAG 2.2: https://www.w3.org/TR/WCAG22/#target-size-enhanced +- **S9 [resolvable]:** SC 3.3.8 Accessible Authentication (Minimum), AA — a cognitive function test must not be required unless an alternative or a mechanism to assist exists; password-manager support qualifies — WCAG 2.2: https://www.w3.org/TR/WCAG22/#accessible-authentication-minimum +- **S10 [resolvable]:** Guidance on throttling authentication attempts and on not disclosing lockout proximity — NIST SP 800-63B, *Digital Identity Guidelines: Authentication and Lifecycle Management*: https://pages.nist.gov/800-63-3/sp800-63b.html +- **S11 [resolvable]:** SC 1.4.3 Contrast (Minimum), AA, 4.5:1 and 3:1 for large text — WCAG 2.2: https://www.w3.org/TR/WCAG22/#contrast-minimum +- **S12 [resolvable]:** SC 2.3.3 Animation from Interactions and the `prefers-reduced-motion` media query — WCAG 2.2: https://www.w3.org/TR/WCAG22/#animation-from-interactions · MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion + +## Competitor notes + +**None gathered.** See `GAP1`. No competitor product was inspected during this run, so nothing is recorded here rather than a plausible-sounding summary. **V4 forbids a fabricated citation, and an unsourced competitor observation is the same defect wearing different clothes.** + +## Pattern catalog + +- **P1: Preserve-and-refocus on credential error.** Keep the identifier, clear or keep the secret, move focus to the field to correct, announce in a live region. (sources: [S5, S6]) +- **P2: Identical confirmation for every reset request.** One confirmation screen, naming the address the user typed, shown whether or not the account exists. (sources: [S3, S4]) +- **P3: Throttle the resend, and say so.** A resend control that silently stops working reads as broken. State the wait in copy. (sources: [S4, S10]) +- **P4: Declare the field, do not fight the browser.** Correct `autocomplete` and `type` tokens, 16px minimum text, paste permitted. (sources: [S1, S2, S9]) + +## Constraints + +- Reset-flow responses must not vary by account existence. (sources: [S3, S4]) +- Password paste must not be blocked — blocking it removes the mechanism SC 3.3.8 relies on. (sources: [S9]) +- Field text ≥ 16px on iOS, or the viewport zooms on focus. (sources: [S2]) +- Load-bearing text ≥ 4.5:1, or ≥ 3:1 at ≥ 18.66px. (sources: [S11]) +- Every motion needs a static equivalent under `prefers-reduced-motion`. (sources: [S12]) + +## Contradictions + +_Listed, not resolved. Each is carried to STATE 03 as-is._ + +- **C1: The product's own target floor is stricter than the standard it cites.** This product's AC7.1 requires 44 × 44 (S8, AAA); WCAG 2.2 AA requires 24 × 24 (S7). An element measuring 32px conforms to AA and **fails this product's audit**. Both sources are correct; they are answering different questions. Left unresolved here — it is a product decision, and STATE 03 owns it. +- **C2: "Announce the error" and "move focus to the field" can conflict.** `role="alert"` announces without moving focus (S5); SC 3.3.1 requires the item in error be identified (S6). Doing both naively can cause a screen reader to interrupt itself. Left unresolved: the ordering is a STATE 06 decision, and STATE 08 is where it is verified. + +## Goal coverage + +- **G1** → [T1, T5] · **G2** → [T1, T2, T3, T6] · **G3** → [T2] · **G4** → [T3, T4, T5] + +4 of 4 goals mapped. **0 waived.** Coverage threshold met at 100%. + +## Gaps + +- **GAP1 [competitor scan not performed]:** No live competitor inspection was carried out in this run, so the **Competitor notes** section is empty rather than populated. This is recorded as a gap, not filled with a plausible summary — a fabricated observation is a V4 failure and is more expensive than an empty section, because it looks like evidence. **What would close it:** inspect three comparable mobile sign-in flows and record what each does at the invalid-credentials and reset-confirmation moments. +- **GAP2 [server policy unavailable]:** The real lockout threshold and window are the auth service's, and were not available during this run. Assumption A3 (5 / 15 min) stands in, carried as `o-s1`. **What would close it:** one line from whoever owns the auth service. diff --git a/design-toolkit/examples/signin/artifacts/review-record-signin.md b/design-toolkit/examples/signin/artifacts/review-record-signin.md new file mode 100644 index 0000000..7ffd881 --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/review-record-signin.md @@ -0,0 +1,128 @@ +--- +artifact: review-record +version: review-signin-01 +produced_by: user-review +reads_versions: + prototype: proto-signin-02 # the bytes REVIEWED + audit-report-signin.md: audit-signin-03 + traceability-signin.md: trace-signin-02 + revision-log-signin.md: rev-signin-01 +scoped_to: proto-signin-03 # the bytes the approval COVERS, after one ratified bug-fix delta +feature: signin +date: "2026-08-07" +gate: PrimaryUserApprovalGate +verdict: APPROVED +player_url: http://localhost:8765/play.html#signin +--- + +# Gate record — signin + +## Decision + +**APPROVED** on the **1st presentation**, 2026-08-07. + +User instruction, verbatim: *"Approve"* — with the accompanying ruling *"Run STATE 12 too"*, setting `handoff_required: true`. + +| Field | Value | +|---|---| +| Flow | `signin` — `S-SIGN-01`, `S-SIGN-02`, `S-SIGN-03` | +| Prototype at decision | `proto-signin-02` | +| Approval **scoped to** | `proto-signin-03` — one bug-fix-only delta, ratified below | +| Audit of record | `audit-signin-03` — **PASS 26 / 26 ACs**, re-run on `proto-signin-03` | +| Reviewed at | `http://localhost:8765/play.html#signin` (Run Local, V4) | +| Passes to approval | **1** — `pass N = 1 + revision rounds delivered`; one revision cycle was consumed **before** first presentation, so nothing had been shown before this | + +The prototype was served through `run-local.sh` / `serve.py` and reviewed through the player, not as file previews. Live reload was confirmed active, which is itself evidence the machine record was current: `serve.py` gates it on the top-level `current_state` being `USER_REVIEW`, and it was. + +## What was approved + +- **Credential entry** with correct autofill declaration, 16px field text, and a submit control inert until both fields carry content. +- **In-place recovery**: a rejected attempt preserves the entered identifier, moves focus to the secret field, and announces a message that names neither credential. +- **A self-service reset route** reachable in one tap, carrying the typed identifier forward, with **no account-exists branch anywhere in the flow** — the same acknowledgement is shown whether or not the address is registered. +- **A throttled resend** whose control is disabled while the throttle holds, with the wait stated in visible copy. +- **Eleven flow states**, each reachable by a deep-link hook and each proven to paint. +- **A 44px interactive floor**, ruled at the Direction Gate over WCAG 2.2 AA's 24px, and enforced by the audit at that number. +- **`handoff_required: true`** — STATE 12 runs before delivery. + +This is what `REVISION` and `FINAL_OUTPUT` treat as settled. + +## Verification at approval + +| Check | Result | +|---|---| +| Audit | `audit-signin-03` — **PASS 26 / 26 ACs**, 0 unmet, 0 waived, re-run on the scoped bytes | +| Assertions (rendering-class) | **138 / 138**, stable over two consecutive full runs | +| Runs | 22 — 11 flow states × 2 passes (`base`, `reduced-motion`) | +| Console | **0 errors** (`favicon.ico` filtered by name, not wholesale) | +| Screenshots | **22 captured and read.** The re-read after the rebuild is what found AF-5 | +| Source sweeps | off-palette **0** · duplicate keys **0** · network call sites **0** | +| Revision loop | `L_REVISION` **1 / 3** | + +## Change requests + +**None.** The verdict was `approve`, so no change request was raised (V2 is inapplicable, V3 is vacuously satisfied — there was nothing to drop). + +## Deltas ratified by this decision + +| # | Delta | Class | Evidence | +|---|---|---|---| +| 1 | Reworded one source comment. It recited the request-API names, so `annotate`'s network sweep matched the **disclaimer claiming there were no network calls** (`E11`, blocking). Plus a third stale version string the AF-5 sweep missed. | **bug-fix only** | hex/token inventory **identical** (12 tokens) · diff confined to **one named region**, a source comment · the pre-fix file **reconstructed from the inverse delta hashes back to `04f2c92…c0512`**, the approved sha — exact match · full audit re-run on the new bytes: **138/138** | + +**Classified before it was asked about** (G4). The class earned a **one-line scope confirm**, not a ruling. + +User instruction, verbatim: *"Extend previous approval. Only bug fixes were made. Reuse the previous approval for this revision."* + +The gate reverted to `pending` the moment the bytes moved — that reversion is automatic, and it is the rule that stops a deliverable and a prototype disagreeing about what shipped. It re-scoped to `proto-signin-03` on the confirm above. + +No user-visible surface changed: the screenshots taken against `proto-signin-03` are indistinguishable from the ones reviewed against `proto-signin-02`. + +## Known limitations presented + +Verbatim from `audit-signin-03`, carried rather than laundered. The approval is **scoped to a deliverable that carries all six**. + +1. **The lockout window is an assumed number rendered as fact** — *"Try again in 15 minutes"* (`o-s1`, AF-4). **Rides on debt #1.** Closes when the auth service owner states the real window. +2. **Every string is placeholder and unreviewed** (`o-s3`). Nobody has claimed copy ownership for this flow. +3. **Every response is simulated** (A2). The network sweep **measures** this — 0 call sites — but nothing here proves the real service behaves as designed. +4. **No competitor scan was performed** (`res-signin-01` GAP1). The design rests on standards and pattern evidence, not on what comparable products do. +5. **`ds-banner` has no `offline` variant** (Extension Note 1). *"Could not reach"* and *"was refused"* are held apart by **copy**, not by structure — one revision away from being lost. +6. **`o-s2` (reveal-the-secret) was ruled out of scope**, not built and rejected. The design system has no primitive. + +**This is an acceptance with qualifications, and it is recorded with its qualifications.** A clean-looking record of a qualified acceptance is a false record, and it is the artifact delivery is checked against. + +## Opens carried forward + +| ID | Question | Ships as | +|---|---|---| +| `o-s1` | Lockout threshold and window — assumed 5 attempts / 15 minutes | The assumed value in one string, plus **debt #1** and Known limitation 1 | +| `o-s3` | Copy ownership | Placeholder strings, plus Known limitation 2 | + +`o-s2`, `o-s4` and `o-s5` are **closed** — at STATE 06, at the Direction Gate, and at STATE 06-verified-by-STATE-08 respectively. + +## Validations waived + +| Rule | Why | Granted by | Rides on | +|---|---|---|---| +| — | **none** | — | — | + +No validation was waived at this gate. The audit of record ran on the bytes being approved (V6 satisfied without a rider), and the traceability matrix exists and is current (`trace-signin-02`). + +Recorded as an empty table rather than omitted: an absent waiver section and a table stating *none* are different claims. + +## Freeze hashes + +Recorded at the moment of approval, **re-recorded after the ratified delta**. An approval is scoped to the bytes it saw — these are those bytes, as re-scoped. + +The reviewed sha for `signin.html` was `04f2c9288d2c6e180a238247ca91a8c30e8636c89ef2fabc72a7f862463c0512` (`proto-signin-02`). Both are named, because a record that quietly re-freezes under a new hash is the **silent re-freeze** that G4 exists to prevent — the shape to copy is one that says out loud when the bytes moved between review and approval. + +| Deliverable file | sha256 | +|---|---| +| `prototype/signin.html` | `ac31e27100c8c729a1173ee6252ada8f8663ecd46e6a4e15c2c130aa163eb56f` | +| `prototype/play.html` | `3277901ea9f3365ca6a068e6a60588683254b4e940f04c46f0c852f462828462` | +| `prototype/serve.py` | `11c6df40390bbd05ec88d0051d6a78ccc7e2b5a34f3756f3da418ed69d0c8d15` | +| `prototype/run-local.sh` | `02173bbd3c1a9fa1c7401fe4f5a316e02c2e970070389f05d3d42e047d50eabb` | + +`FINAL_OUTPUT` compares these against the bytes it freezes. If they differ, the gate reverts to `pending` and the delta is classified before anything is packaged. + +## Exit + +`approve` **+** `C_HANDOFF_REQUIRED` → **`FLOW_VISUALIZATION`**, then `FINAL_OUTPUT`. diff --git a/design-toolkit/examples/signin/artifacts/revision-log-signin.md b/design-toolkit/examples/signin/artifacts/revision-log-signin.md new file mode 100644 index 0000000..08611a4 --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/revision-log-signin.md @@ -0,0 +1,102 @@ +--- +artifact: revision-log +version: rev-signin-01 +produced_by: revision +reads_versions: + audit-report-signin.md: audit-signin-01 +iteration: 1 +loop: L_REVISION (1/3) +feature: signin +--- + +# Iteration 1 — source: audit verdict `fail`, 2026-08-07 + +No `review-record` exists yet — this loop was entered from `SELF_AUDIT`, not from the gate. The user has not seen the prototype. + +## Change set + +| # | Change item | Raised by | Class + sweep | Root cause | Target state | Status | +|---|---|---|---|---|---|---| +| **CR1** | *"`error{invalid-credentials}` renders with an empty email field, so AC2.1 cannot be demonstrated"* | self-audit AF-1 | **Class: a hook that seeds a VIEW state does not seed the DATA the criterion is about.** Swept all 11 hooks: **4 instances** — `signin&state=invalid`, `signin&state=locked`, `signin&state=offline`, `reset&state=bademail`. A fifth, `?view=sent`, showed the *placeholder* address rather than a submitted one — same class, different symptom. **5 fixed, not 1.** | `PROTOTYPE` — the spec was right; the assembly seeded the view and not the data | `PROTOTYPE` | **resolved** | +| **CR2** | *"`error{resend-throttled}` shows 'ask again in 60 seconds' above a live resend button"* | self-audit AF-2 | **Class: rendered copy contradicting the state it describes.** Swept every state that makes a claim about a control: 3 candidates (`throttled`, `loading` ×2). `loading` already enforced it via `aria-busy` + `disabled`; **`throttled` was the only unenforced one.** 1 instance, sweep recorded. | `PROTOTYPE` — AC5.2 says the control *is* throttled; the build said so and did not do so | `PROTOTYPE` | **resolved** | +| **CR3** | *"`S-SIGN-03` carries a toolbar Back that `ux-signin-01` explicitly rules out"* | self-audit AF-3 | **Class: a downstream plan contradicting the plan above it.** Swept `ui-signin-01`'s inventory against `ux-signin-01`'s navigation model row by row: **1 instance** — the `ds-toolbar + back` assignment to the acknowledgement screen. `S-SIGN-02`'s toolbar is correct (that screen is reached by **push**). | **`UI_PLANNING`** — *not* `PROTOTYPE`. The build faithfully implemented its spec; the spec was wrong. Dispatching this to `PROTOTYPE` would have fixed the symptom and left `ui-plan` still saying the opposite | `UI_PLANNING` → `PROTOTYPE` | **resolved** | +| **CR4** | *"The prototype's own version readout still said `proto-signin-01` after the rebuild"* | screenshot re-read, iteration 1 | **Class: an artifact that cannot say which bytes it is.** Swept for version strings in the prototype: **2 instances** — `` and the demo bar label. Both bumped. | `PROTOTYPE` | `PROTOTYPE` | **resolved** | +| CR5 | *"`locked{rate-limited}` renders the assumed 15-minute window as fact"* | self-audit AF-4 | Single instance, one string. Not a build defect — an **open decision reaching the surface**, which is the thing `o-s1` exists to track. | — | **not dispatched** | **deferred** — debt #1, ships in Known limitations | + +_Every item carries a target state and a status (V1). Nothing exits `open` (V2)._ + +## Conflicts — Conflict Mini-Gate + +| # | Conflict | Side A | Side B | Ships as | Gate outcome | +|---|---|---|---|---|---| +| — | none this iteration | — | — | — | — | + +CR3 looks like a conflict and is not one: `ux-signin-01` and `ui-signin-01` are not peers. The UX plan is upstream, so this is a **misrouted spec**, resolved by correcting the downstream document — not a contradiction between two ratified decisions needing a user ruling. + +## Dependency order (upstream → downstream) + +1. `UI_PLANNING` → **`ui-signin-02`** (supersedes `ui-signin-01`) — CR3. The acknowledgement screen's inventory row loses `ds-toolbar + back`. +2. `PROTOTYPE` → **`proto-signin-02`** — CR1, CR2, CR3 (implementation), CR4. +3. `SELF_AUDIT` → **`audit-signin-02`**, re-run on the rebuilt bytes. +4. `USER_REVIEW` → first presentation. The Primary gate has never been granted, so there is no approval to revert. + +## Superseded by this revision + +| Component / rule / string | Superseded by | Removed from | +|---|---|---| +| `ds-toolbar + back` on the acknowledgement screen | the explicit `Back to sign in` route already present | `ui-signin-01` inventory row → `ui-signin-02`; `.sn-bar` markup and the `#b-back-reset` **click handler** in `signin.html` | + +**The handler went with the markup.** A leftover listener bound to a removed id is an un-specced element and fails V2 exactly as an addition does (B7). Strip verified by grep: the only remaining occurrence of `b-back-reset` is the comment recording that it was removed. + +## Constraints recorded + +- **A hook must seed the data its state implies, not only the view.** Discovered by CR1 and now a standing constraint for this prototype: any future state reachable only after a submit seeds the values that submit carried. Otherwise the review packet shows the state and hides the criterion. +- **A control that a banner describes must enforce what the banner claims.** From CR2. The copy is not the mechanism. + +## Deliberately not changed + +| Item | Why not | +|---|---| +| CR5 — the 15-minute lockout string | It is an **open decision** (`o-s1`), not a defect. Replacing an assumed number with a different assumed number changes nothing and hides that nobody has ruled it. Ships as debt #1 with a stated closing condition. | +| `ds-banner` having no `offline` variant | Extension Note 1. The distinction is carried by copy in this pass. Adding a variant is a design-system change, not a revision item, and inventing one here would be a `new` component with no justification. | +| The competitor-scan gap (`GAP1`) | Recorded in `res-signin-01`. Filling it is a research round, not a revision. | +| `paletteExemptSelectors` being dead config (TK-2) | A toolkit finding, not a `signin` finding. Removing a documented config key is a decision, not an audit outcome. Recorded in `audit-signin-01`. | + +_Recorded so silence is not mistaken for oversight (V2)._ + +## Impact analysis + +| Artifact | Effect | +|---|---| +| `requirements-signin.md` | unchanged — `req-signin-01` | +| `research-signin.md` | unchanged — `res-signin-01` | +| `product-review-signin.md` | unchanged — `pr-signin-01`, Direction Gate still granted (nothing it approved moved) | +| `ux-plan-signin.md` | unchanged — `ux-signin-01`. **It was right.** | +| `flows-signin.md` | unchanged — `flow-signin-01` | +| `ui-plan-signin.md` | **`ui-signin-02`** (supersedes `ui-signin-01`) | +| `prototype/` | **`proto-signin-02`** | +| `traceability-signin.md` | **`trace-signin-02`** | +| `audit-report-signin.md` | **`audit-signin-02`** | + +## Re-validation + +| Check | Result | +|---|---| +| Re-audit on the rebuilt bytes | **`audit-signin-02` PASS 26 / 26 ACs** — run against `proto-signin-02`, not waived (R6, V7) | +| Rendering-class assertions | **138 / 138**, stable across two consecutive runs, 22 runs each | +| Screenshots re-read | **22**, and the re-read is what found CR4 | +| Class sweeps from R2 | CR1: 5 instances, all fixed · CR2: 3 candidates, 1 unenforced, fixed · CR3: 1 instance · CR4: 2 instances | +| `node --check` | clean | +| Supersession strip | verified by grep — 0 dead references | + +## Validation self-check + +- **V1** ✅ — every item has a target state and a status. +- **V2** ✅ — no item left `open`. CR5 is `deferred` **with a reason** and a debt id. +- **V3** ✅ — iteration 1; `L_REVISION 1/3` written in frontmatter, in `machine_state.loop_count`, and here. `L_AUDIT_FIX 0/3` — this was a full rebuild, not an in-place audit fix. +- **V4** ✅ — every item names its **class** and its sweep result. CR1 was reported as one instance and swept to **five**. +- **V5** ✅ — the superseded toolbar was removed with its handler; no carried items existed to re-verify (first iteration). +- **V6** ✅ — no conflicts this iteration, and the CR3 near-miss is explained rather than left implicit. +- **V7** ✅ — the return edge passes through `SELF_AUDIT` on the rebuilt bytes. **No waiver required.** + +**Exit:** dispatch to `UI_PLANNING` → `PROTOTYPE` → `SELF_AUDIT` → `USER_REVIEW`. diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-01-happy-reduced-motion.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-happy-reduced-motion.png new file mode 100644 index 0000000..100f4af Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-happy-reduced-motion.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-01-happy.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-happy.png new file mode 100644 index 0000000..100f4af Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-happy.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-01-invalid-credentials-reduced-motion.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-invalid-credentials-reduced-motion.png new file mode 100644 index 0000000..a6b45c0 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-invalid-credentials-reduced-motion.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-01-invalid-credentials.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-invalid-credentials.png new file mode 100644 index 0000000..a6b45c0 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-invalid-credentials.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-01-loading-reduced-motion.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-loading-reduced-motion.png new file mode 100644 index 0000000..4c16e78 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-loading-reduced-motion.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-01-loading.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-loading.png new file mode 100644 index 0000000..4c16e78 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-loading.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-01-locked-rate-limited-reduced-motion.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-locked-rate-limited-reduced-motion.png new file mode 100644 index 0000000..7409f30 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-locked-rate-limited-reduced-motion.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-01-locked-rate-limited.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-locked-rate-limited.png new file mode 100644 index 0000000..7409f30 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-locked-rate-limited.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-01-offline-reduced-motion.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-offline-reduced-motion.png new file mode 100644 index 0000000..4d2bce0 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-offline-reduced-motion.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-01-offline.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-offline.png new file mode 100644 index 0000000..4d2bce0 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-01-offline.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-02-happy-reduced-motion.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-happy-reduced-motion.png new file mode 100644 index 0000000..1fee575 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-happy-reduced-motion.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-02-happy.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-happy.png new file mode 100644 index 0000000..1fee575 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-happy.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-02-invalid-email-reduced-motion.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-invalid-email-reduced-motion.png new file mode 100644 index 0000000..87e90ef Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-invalid-email-reduced-motion.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-02-invalid-email.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-invalid-email.png new file mode 100644 index 0000000..87e90ef Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-invalid-email.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-02-loading-reduced-motion.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-loading-reduced-motion.png new file mode 100644 index 0000000..d24d8e5 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-loading-reduced-motion.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-02-loading.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-loading.png new file mode 100644 index 0000000..d24d8e5 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-02-loading.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-03-happy-reduced-motion.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-happy-reduced-motion.png new file mode 100644 index 0000000..cb10f45 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-happy-reduced-motion.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-03-happy.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-happy.png new file mode 100644 index 0000000..cb10f45 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-happy.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-03-resend-throttled-reduced-motion.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-resend-throttled-reduced-motion.png new file mode 100644 index 0000000..2a6361e Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-resend-throttled-reduced-motion.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-03-resend-throttled.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-resend-throttled.png new file mode 100644 index 0000000..2a6361e Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-resend-throttled.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-03-success-resent-reduced-motion.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-success-resent-reduced-motion.png new file mode 100644 index 0000000..e32e004 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-success-resent-reduced-motion.png differ diff --git a/design-toolkit/examples/signin/artifacts/shots/s-sign-03-success-resent.png b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-success-resent.png new file mode 100644 index 0000000..e32e004 Binary files /dev/null and b/design-toolkit/examples/signin/artifacts/shots/s-sign-03-success-resent.png differ diff --git a/design-toolkit/examples/signin/artifacts/stategraph.json b/design-toolkit/examples/signin/artifacts/stategraph.json new file mode 100644 index 0000000..946973c --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/stategraph.json @@ -0,0 +1,292 @@ +{ + "generated_by": "tools/stategraph.mjs", + "inputs": { + "registry": "reference/screen-registry.csv", + "machines": "reference/state-machines.json", + "vocabulary": null + }, + "counts": { + "screens": 3, + "states": 11, + "transitions": 17, + "multiState": 3, + "singleState": 0, + "hooked": 11 + }, + "stateUse": { + "happy": 3, + "loading": 2, + "error{invalid-credentials}": 1, + "locked{rate-limited}": 1, + "offline": 1, + "error{invalid-email}": 1, + "success{resent}": 1, + "error{resend-throttled}": 1 + }, + "id_conflicts": [], + "machines": [ + { + "id": "S-SIGN-01", + "flow": "01 Sign In", + "name": "Sign in", + "initial": "happy", + "note": "Entry point of the unauthenticated area. Success leaves this flow into ⟂HOME-01, which is a boundary rather than a state of this screen.", + "states": [ + { + "state": "happy", + "hook": "signin.html?view=signin", + "note": "Default entry. Submit is inert until both fields carry content (D1 is a guard on the affordance, not a branch).", + "entry_only": false, + "terminal": false, + "unimplemented": false, + "reason": null, + "reachable": true + }, + { + "state": "loading", + "hook": "signin.html?view=signin&state=loading", + "note": "aria-busy, label swapped, second submit impossible.", + "entry_only": false, + "terminal": false, + "unimplemented": false, + "reason": null, + "reachable": true + }, + { + "state": "error{invalid-credentials}", + "hook": "signin.html?view=signin&state=invalid", + "note": "Identifier preserved, focus on the secret, announced via a role=alert container that pre-exists in the DOM (o-s5).", + "entry_only": false, + "terminal": false, + "unimplemented": false, + "reason": null, + "reachable": true + }, + { + "state": "locked{rate-limited}", + "hook": "signin.html?view=signin&state=locked", + "note": "TERMINAL AT SCREEN LEVEL, not a dead end. No in-screen transition changes it — the window elapses off-screen, and the way out is the reset route, which leaves for S-SIGN-02. That cross-screen edge is navgraph's to draw, not this machine's. The banner renders the ASSUMED 15-minute window (o-s1, debt #1). Flagged rather than left to report as S7 every run: a suppressed advisory teaches a reader to skim.", + "entry_only": false, + "terminal": true, + "unimplemented": false, + "reason": null, + "reachable": true + }, + { + "state": "offline", + "hook": "signin.html?view=signin&state=offline", + "note": "Distinguished from a rejected credential by copy only — ds-banner has no offline variant (Extension Note 1).", + "entry_only": false, + "terminal": false, + "unimplemented": false, + "reason": null, + "reachable": true + } + ], + "transitions": [ + { + "from": "happy", + "to": "loading", + "trigger": "submit with both fields non-empty (D1, D2)", + "kind": "user", + "evidence": "signin.html:364" + }, + { + "from": "error{invalid-credentials}", + "to": "loading", + "trigger": "correct the secret and resubmit", + "kind": "user", + "evidence": "signin.html:364" + }, + { + "from": "offline", + "to": "loading", + "trigger": "retry", + "kind": "user", + "evidence": "signin.html:364" + }, + { + "from": "loading", + "to": "error{invalid-credentials}", + "trigger": "D3 no, attempts < N", + "kind": "system", + "evidence": "signin.html:299" + }, + { + "from": "loading", + "to": "locked{rate-limited}", + "trigger": "D3 no, attempts >= N", + "kind": "system", + "evidence": "signin.html:299" + }, + { + "from": "loading", + "to": "offline", + "trigger": "D2 no — request did not reach the service", + "kind": "system", + "evidence": "signin.html:299" + }, + { + "from": "happy", + "to": "error{invalid-credentials}", + "trigger": "entry on a URL hook — QA and review path", + "kind": "entry", + "evidence": "signin.html:367" + }, + { + "from": "happy", + "to": "locked{rate-limited}", + "trigger": "entry on a URL hook — QA and review path", + "kind": "entry", + "evidence": "signin.html:367" + }, + { + "from": "happy", + "to": "offline", + "trigger": "entry on a URL hook — QA and review path", + "kind": "entry", + "evidence": "signin.html:367" + } + ] + }, + { + "id": "S-SIGN-02", + "flow": "01 Sign In", + "name": "Reset password", + "initial": "happy", + "note": "Reached by PUSH from S-SIGN-01, so its toolbar back is correct. Carries the identifier already typed (AC4.2).", + "states": [ + { + "state": "happy", + "hook": "signin.html?view=reset", + "note": "Identifier pre-filled from S-SIGN-01 when one was typed.", + "entry_only": false, + "terminal": false, + "unimplemented": false, + "reason": null, + "reachable": true + }, + { + "state": "loading", + "hook": "signin.html?view=reset&state=loading", + "note": "aria-busy on the send control.", + "entry_only": false, + "terminal": false, + "unimplemented": false, + "reason": null, + "reachable": true + }, + { + "state": "error{invalid-email}", + "hook": "signin.html?view=reset&state=bademail", + "note": "The ONLY error this branch may show. There is deliberately no account-exists branch (D-f2) — adding one would build an enumeration oracle.", + "entry_only": false, + "terminal": false, + "unimplemented": false, + "reason": null, + "reachable": true + } + ], + "transitions": [ + { + "from": "happy", + "to": "loading", + "trigger": "submit with a well-formed identifier (D4 yes)", + "kind": "user", + "evidence": "signin.html:359" + }, + { + "from": "happy", + "to": "error{invalid-email}", + "trigger": "submit with a malformed identifier (D4 no)", + "kind": "user", + "evidence": "signin.html:303" + }, + { + "from": "error{invalid-email}", + "to": "loading", + "trigger": "correct the identifier and resubmit", + "kind": "user", + "evidence": "signin.html:359" + }, + { + "from": "loading", + "to": "error{invalid-email}", + "trigger": "D5 no — request did not reach the service", + "kind": "system", + "evidence": "signin.html:304" + } + ] + }, + { + "id": "S-SIGN-03", + "flow": "01 Sign In", + "name": "Check your email", + "initial": "happy", + "note": "Reached by REPLACE (D-f3), which is why it carries an explicit route back instead of relying on browser back. The toolbar back that proto-signin-01 had here was superseded by rev-signin-01 / CR3.", + "states": [ + { + "state": "happy", + "hook": "signin.html?view=sent", + "note": "Names the address the link was sent to (AC5.1).", + "entry_only": false, + "terminal": false, + "unimplemented": false, + "reason": null, + "reachable": true + }, + { + "state": "success{resent}", + "hook": "signin.html?view=sent&state=resent", + "note": "role=status rather than role=alert — a confirmation should not interrupt.", + "entry_only": false, + "terminal": false, + "unimplemented": false, + "reason": null, + "reachable": true + }, + { + "state": "error{resend-throttled}", + "hook": "signin.html?view=sent&state=throttled", + "note": "The resend control is DISABLED here, not merely captioned. rev-signin-01 / CR2 — copy is not the mechanism.", + "entry_only": false, + "terminal": false, + "unimplemented": false, + "reason": null, + "reachable": true + } + ], + "transitions": [ + { + "from": "happy", + "to": "success{resent}", + "trigger": "resend outside the throttle window (D6 no)", + "kind": "user", + "evidence": "signin.html:365" + }, + { + "from": "success{resent}", + "to": "success{resent}", + "trigger": "resend again outside the window", + "kind": "user", + "evidence": "signin.html:365" + }, + { + "from": "happy", + "to": "error{resend-throttled}", + "trigger": "resend inside the throttle window (D6 yes)", + "kind": "user", + "evidence": "signin.html:317" + }, + { + "from": "error{resend-throttled}", + "to": "happy", + "trigger": "the window elapses", + "kind": "system", + "evidence": "signin.html:317" + } + ] + } + ], + "findings": [] +} \ No newline at end of file diff --git a/design-toolkit/examples/signin/artifacts/statemap-report.md b/design-toolkit/examples/signin/artifacts/statemap-report.md new file mode 100644 index 0000000..9b7da54 --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/statemap-report.md @@ -0,0 +1,93 @@ +# Screen State Machines — derivation report + +_Generated by `tools/stategraph.mjs` from `reference/screen-registry.csv` + `reference/state-machines.json`._ + +## Counts + +| Metric | Value | +|---|---| +| Screens | 3 | +| States | 11 | +| Transitions | 17 | +| Multi-state screens | 3 | +| Single-state screens | 0 | +| States with a QA hook | 11 | + +## Findings + +None. + +## Per-screen machines + +### 01 Sign In + +**S-SIGN-01 · Sign in** — initial `happy`, 5 state(s), 9 transition(s) + +_Entry point of the unauthenticated area. Success leaves this flow into ⟂HOME-01, which is a boundary rather than a state of this screen._ + +| State | Hook | Flags | +|---|---|---| +| `happy` | `signin.html?view=signin` | — | +| `loading` | `signin.html?view=signin&state=loading` | — | +| `error{invalid-credentials}` | `signin.html?view=signin&state=invalid` | — | +| `locked{rate-limited}` | `signin.html?view=signin&state=locked` | terminal | +| `offline` | `signin.html?view=signin&state=offline` | — | + +| From | → | To | Trigger | Kind | Evidence | +|---|---|---|---|---|---| +| `happy` | → | `loading` | submit with both fields non-empty (D1, D2) | user | `signin.html:364` | +| `error{invalid-credentials}` | → | `loading` | correct the secret and resubmit | user | `signin.html:364` | +| `offline` | → | `loading` | retry | user | `signin.html:364` | +| `loading` | → | `error{invalid-credentials}` | D3 no, attempts < N | system | `signin.html:299` | +| `loading` | → | `locked{rate-limited}` | D3 no, attempts >= N | system | `signin.html:299` | +| `loading` | → | `offline` | D2 no — request did not reach the service | system | `signin.html:299` | +| `happy` | → | `error{invalid-credentials}` | entry on a URL hook — QA and review path | entry | `signin.html:367` | +| `happy` | → | `locked{rate-limited}` | entry on a URL hook — QA and review path | entry | `signin.html:367` | +| `happy` | → | `offline` | entry on a URL hook — QA and review path | entry | `signin.html:367` | + +**S-SIGN-02 · Reset password** — initial `happy`, 3 state(s), 4 transition(s) + +_Reached by PUSH from S-SIGN-01, so its toolbar back is correct. Carries the identifier already typed (AC4.2)._ + +| State | Hook | Flags | +|---|---|---| +| `happy` | `signin.html?view=reset` | — | +| `loading` | `signin.html?view=reset&state=loading` | — | +| `error{invalid-email}` | `signin.html?view=reset&state=bademail` | — | + +| From | → | To | Trigger | Kind | Evidence | +|---|---|---|---|---|---| +| `happy` | → | `loading` | submit with a well-formed identifier (D4 yes) | user | `signin.html:359` | +| `happy` | → | `error{invalid-email}` | submit with a malformed identifier (D4 no) | user | `signin.html:303` | +| `error{invalid-email}` | → | `loading` | correct the identifier and resubmit | user | `signin.html:359` | +| `loading` | → | `error{invalid-email}` | D5 no — request did not reach the service | system | `signin.html:304` | + +**S-SIGN-03 · Check your email** — initial `happy`, 3 state(s), 4 transition(s) + +_Reached by REPLACE (D-f3), which is why it carries an explicit route back instead of relying on browser back. The toolbar back that proto-signin-01 had here was superseded by rev-signin-01 / CR3._ + +| State | Hook | Flags | +|---|---|---| +| `happy` | `signin.html?view=sent` | — | +| `success{resent}` | `signin.html?view=sent&state=resent` | — | +| `error{resend-throttled}` | `signin.html?view=sent&state=throttled` | — | + +| From | → | To | Trigger | Kind | Evidence | +|---|---|---|---|---|---| +| `happy` | → | `success{resent}` | resend outside the throttle window (D6 no) | user | `signin.html:365` | +| `success{resent}` | → | `success{resent}` | resend again outside the window | user | `signin.html:365` | +| `happy` | → | `error{resend-throttled}` | resend inside the throttle window (D6 yes) | user | `signin.html:317` | +| `error{resend-throttled}` | → | `happy` | the window elapses | system | `signin.html:317` | + +## State usage across the product + +| State | Screens | +|---|---| +| `happy` | 3 | +| `loading` | 2 | +| `error{invalid-credentials}` | 1 | +| `error{invalid-email}` | 1 | +| `error{resend-throttled}` | 1 | +| `locked{rate-limited}` | 1 | +| `offline` | 1 | +| `success{resent}` | 1 | diff --git a/design-toolkit/examples/signin/artifacts/stateprobe.json b/design-toolkit/examples/signin/artifacts/stateprobe.json new file mode 100644 index 0000000..5d48bd0 --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/stateprobe.json @@ -0,0 +1,266 @@ +{ + "generated_by": "tools/stateprobe.mjs", + "counts": { + "urls": 11, + "stateClaims": 11, + "painted": 11, + "failed": 0, + "driftObserved": 0 + }, + "measured_id_drift": [], + "results": [ + { + "hook": "signin.html?view=signin", + "claims": [ + { + "screen": "S-SIGN-01", + "state": "happy" + } + ], + "activeViews": [ + "signin" + ], + "paintedViews": [ + "signin" + ], + "sid": "S-SIGN-01", + "screenBox": { + "w": 393, + "h": 852 + }, + "visibleNodes": 11, + "consoleErrors": [], + "ok": true + }, + { + "hook": "signin.html?view=signin&state=loading", + "claims": [ + { + "screen": "S-SIGN-01", + "state": "loading" + } + ], + "activeViews": [ + "signin" + ], + "paintedViews": [ + "signin" + ], + "sid": "S-SIGN-01", + "screenBox": { + "w": 393, + "h": 852 + }, + "visibleNodes": 11, + "consoleErrors": [], + "ok": true + }, + { + "hook": "signin.html?view=signin&state=invalid", + "claims": [ + { + "screen": "S-SIGN-01", + "state": "error{invalid-credentials}" + } + ], + "activeViews": [ + "signin" + ], + "paintedViews": [ + "signin" + ], + "sid": "S-SIGN-01", + "screenBox": { + "w": 393, + "h": 852 + }, + "visibleNodes": 13, + "consoleErrors": [], + "ok": true + }, + { + "hook": "signin.html?view=signin&state=locked", + "claims": [ + { + "screen": "S-SIGN-01", + "state": "locked{rate-limited}" + } + ], + "activeViews": [ + "signin" + ], + "paintedViews": [ + "signin" + ], + "sid": "S-SIGN-01", + "screenBox": { + "w": 393, + "h": 852 + }, + "visibleNodes": 13, + "consoleErrors": [], + "ok": true + }, + { + "hook": "signin.html?view=signin&state=offline", + "claims": [ + { + "screen": "S-SIGN-01", + "state": "offline" + } + ], + "activeViews": [ + "signin" + ], + "paintedViews": [ + "signin" + ], + "sid": "S-SIGN-01", + "screenBox": { + "w": 393, + "h": 852 + }, + "visibleNodes": 13, + "consoleErrors": [], + "ok": true + }, + { + "hook": "signin.html?view=reset", + "claims": [ + { + "screen": "S-SIGN-02", + "state": "happy" + } + ], + "activeViews": [ + "reset" + ], + "paintedViews": [ + "reset" + ], + "sid": "S-SIGN-02", + "screenBox": { + "w": 393, + "h": 852 + }, + "visibleNodes": 10, + "consoleErrors": [], + "ok": true + }, + { + "hook": "signin.html?view=reset&state=loading", + "claims": [ + { + "screen": "S-SIGN-02", + "state": "loading" + } + ], + "activeViews": [ + "reset" + ], + "paintedViews": [ + "reset" + ], + "sid": "S-SIGN-02", + "screenBox": { + "w": 393, + "h": 852 + }, + "visibleNodes": 10, + "consoleErrors": [], + "ok": true + }, + { + "hook": "signin.html?view=reset&state=bademail", + "claims": [ + { + "screen": "S-SIGN-02", + "state": "error{invalid-email}" + } + ], + "activeViews": [ + "reset" + ], + "paintedViews": [ + "reset" + ], + "sid": "S-SIGN-02", + "screenBox": { + "w": 393, + "h": 852 + }, + "visibleNodes": 12, + "consoleErrors": [], + "ok": true + }, + { + "hook": "signin.html?view=sent", + "claims": [ + { + "screen": "S-SIGN-03", + "state": "happy" + } + ], + "activeViews": [ + "sent" + ], + "paintedViews": [ + "sent" + ], + "sid": "S-SIGN-03", + "screenBox": { + "w": 393, + "h": 852 + }, + "visibleNodes": 6, + "consoleErrors": [], + "ok": true + }, + { + "hook": "signin.html?view=sent&state=resent", + "claims": [ + { + "screen": "S-SIGN-03", + "state": "success{resent}" + } + ], + "activeViews": [ + "sent" + ], + "paintedViews": [ + "sent" + ], + "sid": "S-SIGN-03", + "screenBox": { + "w": 393, + "h": 852 + }, + "visibleNodes": 8, + "consoleErrors": [], + "ok": true + }, + { + "hook": "signin.html?view=sent&state=throttled", + "claims": [ + { + "screen": "S-SIGN-03", + "state": "error{resend-throttled}" + } + ], + "activeViews": [ + "sent" + ], + "paintedViews": [ + "sent" + ], + "sid": "S-SIGN-03", + "screenBox": { + "w": 393, + "h": 852 + }, + "visibleNodes": 8, + "consoleErrors": [], + "ok": true + } + ] +} \ No newline at end of file diff --git a/design-toolkit/examples/signin/artifacts/traceability-signin.md b/design-toolkit/examples/signin/artifacts/traceability-signin.md new file mode 100644 index 0000000..8399054 --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/traceability-signin.md @@ -0,0 +1,102 @@ +--- +artifact: traceability +version: trace-signin-02 +supersedes: trace-signin-01 +produced_by: prototype +reads_versions: + requirements-signin.md: req-signin-01 + ux-plan-signin.md: ux-signin-01 + flows-signin.md: flow-signin-01 + ui-plan-signin.md: ui-signin-02 +feature: signin +--- + +# Traceability — signin (`proto-signin-02`) + +Every prototype element traces to a spec entry (V2), and every flow state is represented (V1). File: `artifacts/prototype/signin.html`. + +## Requirement → task → flow → component → prototype element + +| Req | Task | Flow | UI component | Prototype element / hook | +|---|---|---|---|---| +| R1 | TASK-A | F1 | `ds-field` ×2 | `#f-email`, `#f-pass` · `?view=signin` | +| R1 | TASK-A | F1 | `ds-button primary` | `#b-signin`, gated by `gate(['f-email','f-pass'],'b-signin')` | +| R2 | TASK-B | F1 | `ds-banner error` | `.sn-banner-invalid` · `?view=signin&state=invalid` | +| R2 | TASK-B | F1 | focus ruling `o-s5` | `show()` → `pass.focus()` + `aria-invalid=true` | +| R3 | TASK-A | F1 | `ds-button` `loading` | `#b-signin[aria-busy]` · `?view=signin&state=loading` | +| R4 | TASK-C | F1→F2 | `ds-link block` | `#b-forgot` — copies `f-email.value` into `f-reset-email` (AC4.2) | +| R4 | TASK-C | F2 | `ds-field` | `#f-reset-email` · `?view=reset` | +| R5 | TASK-C | F2 | `ds-button ghost` | `#b-resend` · `?view=sent` | +| R5 | TASK-C | F2 | `ds-link block` | `#b-to-signin` (AC5.3) | +| R6 | all | F1, F2 | `ds-banner` ×6 | one per non-happy state, each with its own hook | +| R7 | all | — | layout rules | every interactive box `min-height:48px` against a 44px floor | + +## Flow state → prototype representation (V1) + +| `flow-signin-01` node | Representation | Hook | +|---|---|---| +| `S-SIGN-01 (happy)` | default entry, `.view[data-view=signin]` | `?view=signin` | +| `S-SIGN-01 (loading)` | `data-state=loading` → `#b-signin[aria-busy=true]` | `?view=signin&state=loading` | +| `S-SIGN-01 (error{invalid-credentials})` | `data-state=invalid` → `.sn-banner-invalid` | `?view=signin&state=invalid` | +| `S-SIGN-01 (locked{rate-limited})` | `data-state=locked` → `.sn-banner-locked` | `?view=signin&state=locked` | +| `S-SIGN-01 (offline)` | `data-state=offline` → `.sn-banner-offline` | `?view=signin&state=offline` | +| `S-SIGN-02 (happy)` | `.view[data-view=reset]` | `?view=reset` | +| `S-SIGN-02 (loading)` | `data-state=loading` → `#b-send[aria-busy=true]` | `?view=reset&state=loading` | +| `S-SIGN-02 (error{invalid-email})` | `data-state=bademail` → `.sn-banner-bademail` | `?view=reset&state=bademail` | +| `S-SIGN-03 (happy)` | `.view[data-view=sent]` | `?view=sent` | +| `S-SIGN-03 (success{resent})` | `data-state=resent` → `.sn-banner-resent` | `?view=sent&state=resent` | +| `S-SIGN-03 (error{resend-throttled})` | `data-state=throttled` → `.sn-banner-throttled` | `?view=sent&state=throttled` | + +_All 11 nodes represented. Every one carries a deep-link hook (V5) — a state that cannot be driven cannot be audited by STATE 08 or demonstrated at the STATE 09 gate, and **this table is the review packet**._ + +`⟂HOME-01` is a boundary, not a node in this prototype. Success on `S-SIGN-01` leaves this flow; the mock is the absence of a destination, and it is declared in `flows-signin.md`'s boundary table with the date it was checked. + +## Transition → wiring (V4) + +| Flow transition | Wired as | Destination paints | +|---|---|---| +| `S-SIGN-01 → S-SIGN-01 (loading)` (D1, D2) | `#b-signin` click → `show('signin','loading')` | yes — `s-sign-01-loading.png`, both passes | +| `S-SIGN-01 → S-SIGN-02` (reset route) | `#b-forgot` click → `show('reset',null)` | yes — `s-sign-02-happy.png` | +| `S-SIGN-02 → S-SIGN-01` (back) | `#b-back-signin` → `show('signin',null)` | yes | +| `S-SIGN-02 → S-SIGN-03` (D4 yes, D5 yes) | `#b-send` → sets `#t-addr`, `show('sent',null)` | yes — `s-sign-03-happy.png` | +| `S-SIGN-03 → S-SIGN-03 (resent)` (D6 no) | `#b-resend` → `show('sent','resent')` | yes | +| `S-SIGN-03 → S-SIGN-01` (back to sign in) | `#b-to-signin` → `show('signin',null)` | yes | + +Each destination was driven headlessly and asserted **computed-visible with geometry**, not merely present (B6, M1). + +## Decision → implementation + +| Decision | Where it lives | +|---|---| +| **D-f2** — no `account exists?` branch | The absence itself. There is no such branch anywhere in the source, and `.sn-help` says so on the surface. | +| **D-f3** — `S-SIGN-02 → S-SIGN-03` is a replace | `#b-to-signin`, an explicit route, rather than reliance on browser back. | +| **D-s4** — paste is not blocked | No `paste` handler exists on `#f-pass`. | +| **`o-s5`** — announce, then focus, same frame | `.sn-banner` containers carry `role="alert"` **in the markup**, before content arrives; `pass.focus()` runs in the same `show()` call. | +| **ui-signin-01** — `ds-link` `block` variant only | `.sn-link{min-height:48px}` — no `::after` hit-area expansion anywhere. | +| **B5** — token layer is the base | `.screen{font:… var(--ui-font)}` on the container, never per component. | + +## Superseded — stripped, not left dead (B7) + +| Removed | Superseded by | Selectors / keys stripped | +|---|---|---| +| `ds-toolbar` + `back` on `S-SIGN-03` | the explicit `#b-to-signin` route | `.sn-bar` block in the `sent` view · `#b-back-reset` element · its `addEventListener` handler | + +**Itemised per B7 — supersession deletes.** A leftover listener bound to a removed id is an un-specced element and fails V2 exactly as an addition does. Strip verified by grep: the only surviving occurrence of `b-back-reset` is the comment recording that it was removed. + +## Un-specced additions + +**One, declared:** the `.demo` bar. It is harness chrome, listed as the single `new` entry in `ui-signin-01`'s inventory with its justification, and declared in `audit.paletteExemptSelectors`. It exists so STATE 09 can reach states no data can produce. It is **not** part of any claim about the product. + +Everything else in the prototype maps to an inventory row. + +## Verification record (B8) + +- `node --check` on the extracted `<script>` — **clean**. +- Hex inventory — **12 hexes, all in `:root`, all allowlisted, 0 new**. +- `node tools/smoke.mjs "signin:signin,reset,sent"` — **3 pass / 0 fail**, all painted, printed ids match the registry. +- All 11 hooks driven headlessly across 2 passes — **22 runs**, all painted. +- **22 screenshots captured and read.** Three defects found by reading them (AF-1, AF-2, AF-3) that all 138 assertions passed over. +- Console sweep — **0 errors**, `favicon.ico` filtered by name. +- Duplicate-key sweep — **0**. +- Network call-site sweep — **0**. The "everything is simulated" claim is measured, not asserted. +- Per-glyph font sweep — **not run**; `product.scripts` is empty, which is correct for a single-script product and is recorded rather than skipped silently. diff --git a/design-toolkit/examples/signin/artifacts/ui-plan-signin.md b/design-toolkit/examples/signin/artifacts/ui-plan-signin.md new file mode 100644 index 0000000..e01f8ce --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/ui-plan-signin.md @@ -0,0 +1,212 @@ +--- +artifact: ui-plan +version: ui-signin-02 +supersedes: ui-signin-01 +produced_by: ui-planning +reads_versions: + flows-signin.md: flow-signin-01 + ux-plan-signin.md: ux-signin-01 + research-signin.md: res-signin-01 + design-system: "Toolkit Reference DS · src:toolkit-ref-ds-0.1 · reference/design-system.md" +feature: signin +inherits: "nothing — this is the first flow in this product; there is no shell to inherit" +--- + +# UI Plan — signin + +## Inherited, reused verbatim + +**Nothing.** This is the first flow in the product, so there is no shell, no prior token layer and no established chrome to inherit. Everything below is either a design-system primitive or a `new` composition that argues for itself. + +Recorded explicitly because on a later flow this section is where the reader learns what a plan is *not* claiming to introduce — and an empty one here is a fact, not an omission. + +## Design-system adoption check + +Before anything else, because a plan built on the wrong design system validates perfectly against it: + +| The DS assumes | This product is | Match | +|---|---|---| +| mobile web | mobile web | ✅ | +| 393 × 852 | 393 × 852 (`product.viewport`) | ✅ | +| light theme only | light theme only (A1) | ✅ | +| Latin only | Latin only (`product.scripts` empty) | ✅ | +| LTR only | LTR only (A1) | ✅ | +| target floor 48px | product floor **44px** (`o-s4`, ruled) | ✅ — DS is stricter, so DS primitives clear the product floor by 4px | + +Source id `toolkit-ref-ds-0.1` recorded in `reads_versions.design-system` **and** in `toolkit.config.json` → `designSystem.sourceId`. + +## STRICT colour allowlist (V5) + +``` +brand #2B3FD6 #EEF1FE #FFFFFF +gray #141821 #5A6274 #8A93A6 #F4F6FA #DDE2EC +semantic #B3261E #FDECEA #1B6E3C #E7F4EC +exempt .demo .phone (harness chrome, not app surface) + +BANNED + #FF0000 — the "just use red" default. --danger is #B3261E and it is contrast-checked. + #0000FF — likewise for links. --brand is #2B3FD6. + #000000 — pure black is not in this system. --ink is #141821. + #CCCCCC — the ad-hoc border grey. --line is #DDE2EC. +``` + +Written into `toolkit.config.json` → `audit.colorAllowlist` / `colorBanned` in the same edit as this document. **An allowlist that lives only here is an allowlist nothing enforces** — STATE 08's harness reads the config, not this file. + +The **BANNED** list is not optional. An allowlist alone cannot catch a value that was never supposed to exist: the audit must check **non-DS absence**, not just DS presence. + +> Naming note: this "V5" is the `SELF_AUDIT` hex-conformance rule id. It is not a validation rule of this state — this state's rules are V1–V4 — and it is a different V5 from the rendering-class rule in `skills/08`. + +**All colour is declared once, in `:root`, as custom properties.** Every usage is `var(--token)`. New hex introduced by this plan: **0**. + +## Component inventory → DS mapping (reuse-first) + +| Comp | Surface | DS mapping | new? | +|---|---|---|---| +| Screen container | all 3 screens | `ds-screen` | reuse | +| Top bar with back | reset request **only** | `ds-toolbar` + `back` | reuse | +| Title | all 3 screens | `--t-display` on `ds-screen` | reuse | +| Identifier field | sign in, reset request | `ds-field` + `default` / `error` | reuse+variant | +| Secret field | sign in | `ds-field` + `default` / `error` | reuse+variant | +| Submit control | all 3 screens | `ds-button` + `primary`, with `loading` state | reuse+variant | +| Reset route | sign in | `ds-link` + `block` | reuse+variant | +| Back-to-sign-in route | acknowledgement | `ds-link` + `block` | reuse+variant | +| Resend control | acknowledgement | `ds-button` + `ghost` | reuse+variant | +| Error banner | sign in, reset request | `ds-banner` + `error` | reuse | +| Locked banner | sign in | `ds-banner` + `error` | reuse | +| Offline banner | sign in, reset request | `ds-banner` + `error` | reuse — see Extension Note | +| Resent banner | acknowledgement | `ds-banner` + `success` | reuse | +| Sent-to address | acknowledgement | `--t-body` + `600` weight on `ds-screen` | reuse | +| Demo bar | harness only | — | **new**, justified: review chrome, not product surface. It exists so STATE 09 can drive the states that no data can produce, and it is declared in `audit.paletteExemptSelectors` so its own colours are not swept as app palette. It ships in the prototype and **not** in any deliverable claim about the product. | + +**14 of 15 entries are reuse or a reuse+variant. One `new`, and it is harness chrome rather than product surface.** That is V2's evidence. + +### Selector namespace claimed + +This plan claims `.sn-*` and scopes every descendant rule with `>`. Declared here because the inventory is where a collision is cheap to see, and because generic class names collide silently across screens in a single-file prototype — a list-row class once repainted a hero on another screen, and a descendant selector inflated unrelated icons to ~340px. + +Reserved: `.sn-screen .sn-field .sn-label .sn-input .sn-help .sn-btn .sn-link .sn-banner .sn-title .sn-bar .sn-addr`. Harness chrome uses `.demo` and `.phone`, which are palette-exempt. + +## Layout rules (393 × 852) + +**Vertical budget, sign in:** 852 − 44 (status) − 34 (home indicator) = **774 usable**. + +| Region | Height | Notes | +|---|---|---| +| Top padding | 32 | `--sp-6` | +| Title | 34 | `--t-display`, one line | +| Gap | 24 | `--sp-5` | +| Banner slot | 0 or 64 | present only in error / locked / offline states; it **pushes** content rather than overlaying it | +| Identifier field | 76 | 20 label + 8 gap + 48 input | +| Gap | 16 | `--sp-4` | +| Secret field | 76 | same | +| Gap | 24 | `--sp-5` | +| Submit | 48 | `ds-button` min height | +| Gap | 16 | `--sp-4` | +| Reset route | 48 | `ds-link` **`block`** variant | +| Remaining | ≥ 388 | slack even with the banner present — no state scrolls at 393 × 852 | + +**Reset request:** toolbar 56 + title + one field + submit + helper. **Acknowledgement:** toolbar 56 + title + address line + resend + back route. + +### Interactive target sizes — checked against the floor + +`o-s4` ruled the floor at **44px**. Every interactive element in this plan is specified at **48px**, which clears it by 4px: + +| Element | Box | Floor | Clears | +|---|---|---|---| +| Identifier input | 393−32 × **48** | 44 | ✅ | +| Secret input | 393−32 × **48** | 44 | ✅ | +| Submit | 393−32 × **48** | 44 | ✅ | +| Reset route | 393−32 × **48** | 44 | ✅ | +| Resend | 393−32 × **48** | 44 | ✅ | +| Back-to-sign-in route | 393−32 × **48** | 44 | ✅ | +| Toolbar back | **48 × 48** | 44 | ✅ | + +**Decision: every link in this flow uses the `block` variant, not `inline`.** The `inline` variant meets 44px through an `::after` hit-area expansion, and STATE 08's harness measures the **box**, not the hit area — so an `inline` link would report as a 20px target and be a confirmed-at-source false positive on every single audit run. Choosing the variant whose box is already 48px removes a recurring instrument argument at no design cost. This is a plan decision, recorded, not a prototype improvisation. + +## Motion spec + +| Element | Default | Reduced | +|---|---|---| +| View change | `--m-base` 200ms opacity+translateY(8px) | static — apply end state immediately | +| Banner appear | `--m-fast` 120ms opacity | static | +| Submit → busy | `--m-fast` 120ms label swap | static | +| Rejected attempt | `--m-shake` 300ms | **static — the banner alone carries the signal** | + +Every row is forked for `prefers-reduced-motion`. **No state is signalled by motion alone**: the shake carries nothing the announced banner does not, which is what makes removing it under reduced motion lossless rather than degrading. + +## Contrast + +Decided here, on the token pair, from `reference/design-system.md`'s audited table. Every pair used in this flow: + +| Use | Pair | Ratio | Needs | Verdict | +|---|---|---|---|---| +| Title, field text, address line | `--ink` on `--bg` | 17.8:1 | 4.5 | ✅ | +| Labels, helper copy | `--ink-2` on `--bg` | 6.1:1 | 4.5 | ✅ | +| Submit label | `--brand-ink` on `--brand` | 7.5:1 | 4.5 | ✅ | +| Links, focus ring | `--brand` on `--bg` | 7.5:1 | 4.5 | ✅ | +| Error banner copy | `--danger` on `--danger-weak` | 5.7:1 | 4.5 | ✅ | +| Success banner copy | `--ok` on `--ok-weak` | 5.5:1 | 4.5 | ✅ | + +`--ink-3` (3.1:1) is used **only** for placeholder text, which is never the accessible name of a field and never carries load-bearing copy. Any plan that puts real copy on `--ink-3` is a plan change. + +## `o-s5` — announcement versus focus, RULED here + +`ux-signin-01` U1 and `res-signin-01` C2 left the ordering open. This state owns it. + +**Ruling:** on a rejected attempt — + +1. Render the banner into a container that already carries `role="alert"` in the DOM before the content arrives. A live region that is *created* with its content is unreliably announced; one that already exists and receives content is not. +2. Move focus to the secret field **in the same frame**, not after a delay. +3. The banner copy is the announcement. The secret field's own label is unchanged, so moving focus does not re-announce the error and interrupt it. + +Both AC2.2 (focus on the secret field) and AC2.3 (announced, non-specific) are satisfied without the two fighting. **STATE 08 verifies this rather than assuming it** — `o-s5` closes only when the audit records it. + +## `o-s2` — reveal-the-secret affordance, RULED here + +**Out of scope for this cycle.** The design system has no primitive, so it would be a `new` component with its own state, icon, announcement policy and reduced-motion behaviour — against a `should`-band requirement that does not exist. Recorded in the Extension Note so a DS owner can see the product asked for it. + +## Token reference resolution (V4) + +| Category | Referenced | Resolve in DS | Extension needed | +|---|---|---|---| +| Colour | 12 tokens | 12 | 0 | +| Spacing | `--sp-1`…`--sp-6` | 6 | 0 | +| Type | `--t-display`, `--t-body`, `--t-label`, `--t-small`, `--ui-font` | 5 | 0 | +| Radius | `--r-field`, `--r-btn`, `--r-card` | 3 | 0 | +| Motion | `--m-fast`, `--m-base`, `--m-shake` | 3 | 0 | +| Focus | `--focus-ring` | 1 | 0 | + +**New hex introduced: 0.** Every value resolves in `toolkit-ref-ds-0.1`. Nothing was added to the allowlist to make this plan true. + +## Superseded + +| Component | Replaced by | Strip in prototype | +|---|---|---| +| `ds-toolbar` + `back` on the **acknowledgement** screen | the explicit `Back to sign in` route already at the foot of the screen | **yes** — `.sn-bar` markup *and* the `#b-back-reset` click handler | + +**Superseded by `rev-signin-01` / CR3.** `ux-signin-01`'s navigation model rules that back from the acknowledgement screen is disabled in favour of an explicit route, because the screen is reached by **replace** (D-f3). `ui-signin-01` assigned a toolbar back to it anyway, and `proto-signin-01` implemented the plan faithfully — the build matched its spec and the spec contradicted the one above it. The reset-request screen keeps its toolbar: that screen is reached by **push**, where back is correct. + +## Extension Note (informational, non-blocking) + +Recorded so a design-system owner can see what this product needed and the system did not have. None of these blocks the transition to `PROTOTYPE`. + +1. **`ds-banner` has no `offline` variant.** The offline state reuses `error`, which is correct in tone but conflates "we could not reach the service" with "the service said no" — precisely the distinction `ux-signin-01` asks the design to preserve. It is carried by **copy** in this flow. A dedicated variant would carry it by **structure**. +2. **No countdown / resend-timer primitive.** This is why R5.2 was cut at the Direction Gate. The throttle ships as static copy stating the wait. +3. **No reveal-the-secret primitive** (`o-s2`). Ruled out of scope above. +4. **No inline field-validation timing rule.** This flow validates on submit only, which sidesteps it. A flow with live validation would have to invent the rule. + +## Open decisions + +- **`o-s1`:** attempt threshold — appears in exactly one string on the locked banner. Ruled by: auth service owner. +- **`o-s3`:** copy ownership — every string here is placeholder. Ruled by: content design. + +_`o-s2` and `o-s5` are closed above. `o-s4` was closed at the Direction Gate._ + +## Validation self-check + +- **V1** ✅ — all 11 flow states from `flow-signin-01` map to a component set: `S-SIGN-01` happy/loading/error/locked/offline, `S-SIGN-02` happy/loading/error, `S-SIGN-03` happy/success/error. +- **V2** ✅ — 14 of 15 inventory entries are reuse or reuse+variant. The single `new` is harness chrome and carries its justification. +- **V3** ✅ — no one-off styling where a primitive exists. Every surface maps to `ds-screen`, `ds-field`, `ds-button`, `ds-banner`, `ds-link` or `ds-toolbar`. +- **V4** ✅ — 30 token references, 30 resolve in `toolkit-ref-ds-0.1`, 0 extensions required, **0 new hex**. + +**Exit:** validation passes → `PROTOTYPE` (`proto-signin-01`). diff --git a/design-toolkit/examples/signin/artifacts/ux-plan-signin.md b/design-toolkit/examples/signin/artifacts/ux-plan-signin.md new file mode 100644 index 0000000..d0eddd2 --- /dev/null +++ b/design-toolkit/examples/signin/artifacts/ux-plan-signin.md @@ -0,0 +1,132 @@ +--- +artifact: ux-plan +version: ux-signin-01 +produced_by: ux-planning +reads_versions: + requirements-signin.md: req-signin-01 + research-signin.md: res-signin-01 + product-review-signin.md: pr-signin-01 +feature: signin +coverage: 3/3 tasks with full state enumeration +--- + +# UX Plan — signin + +> **This document is screen-free (V4).** No screen id, no layout, no component name, no colour appears below. It describes the states a user is in and what they experience there. Naming a screen here would pre-commit STATE 06 to a layout nobody chose. + +## Primary tasks (→ requirements) + +Derived from the `must` / `should` bands of `pr-signin-01`, not from the raw requirement set. + +| Task | User intent | Reqs | +|---|---|---| +| **TASK-A** | Get into the product with credentials I know | R1, R3, R7 | +| **TASK-B** | Fix a credential I got wrong, without starting over | R2, R3, R6, R7 | +| **TASK-C** | Get back in when I have forgotten the password | R4, R5, R6, R7 | + +Every task traces to at least one prioritized requirement (V3). + +## Information architecture + +Three regions, and the model is deliberately shallow — an unauthenticated user has one job. + +``` +Unauthenticated area +├── Credential entry the default landing place for a returning user +├── Reset request reachable only from credential entry +└── Reset acknowledgement reachable only from reset request +``` + +- **Credential entry** is the entry point of the whole area. Everything else is downstream of it. +- **Reset request** carries forward whatever identifier the user already typed. It is not a fresh start. +- **Reset acknowledgement** is an end-of-branch, not a dead end: it always offers a way back to credential entry and a way to try the request again. + +Nothing in this area is deep-linkable from outside it in the product sense. The deep-link hooks STATE 07 will add are a **review and audit affordance**, not a user-facing route — the distinction matters because a route that is navigable only by URL is a different claim from one a user can reach. + +## Navigation model + +- **Entry points:** cold start into the unauthenticated area; return from an expired session. +- **Persistent navigation:** none. This area has no chrome beyond a back affordance on the two downstream regions. +- **Push vs replace:** moving to the reset request is a **push** — the user expects back to return them to credential entry with what they typed intact. Moving from reset request to acknowledgement is a **replace**: going "back" into a request already submitted invites a duplicate submission. +- **Back semantics:** back from reset request returns to credential entry, identifier preserved. Back from acknowledgement is disabled in favour of an explicit route. +- **Exit:** success leaves this area entirely, into the authenticated product. That transition is a boundary — it belongs to another flow, and it is marked as one rather than assumed. + +## State enumeration + +### TASK-A — sign in with known credentials + +| State | Kind | Trigger | Strategy | +|---|---|---|---| +| happy | happy | area entered | Both identifier and secret are requested together. The submit affordance is inert until both carry content, and its inertness is conveyed by more than colour. | +| loading | non-happy | submit | The submit affordance becomes busy and cannot be triggered again. The user is told work is happening; the fields stay visible so nothing appears to have been discarded. | +| offline | non-happy | submit with no connectivity | Distinguished from a failed credential: the user is told the attempt did not reach anywhere, and is offered a retry. Nothing they typed is cleared. | +| interrupted | non-happy | the area is left and returned to mid-attempt | Treated as a fresh happy state. Nothing half-submitted is retained, and nothing implies a submission is still in flight. | +| permission-denied | n/a | — | No OS permission is required anywhere in this area. Recorded as inapplicable rather than omitted. | + +### TASK-B — recover from a failed attempt + +| State | Kind | Trigger | Strategy | +|---|---|---|---| +| happy | happy | correction accepted | Indistinguishable from TASK-A's success. Recovery is not a different destination. | +| error (credential rejected) | non-happy | rejected submit | **The identifier survives.** Focus moves to the secret. The message is announced to assistive technology, and it does not reveal which of the two was wrong — that is a security property, not a copy preference. | +| locked (attempts exhausted) | non-happy | repeated rejection | The user is told they must wait and for how long, without being told how close they were. The route to the reset branch stays available — being locked out is the single strongest reason to want it. | +| empty | n/a | — | Neither field can be meaningfully empty *after* an attempt; the inert submit affordance prevents the attempt. Recorded as inapplicable. | +| offline | non-happy | retry with no connectivity | As TASK-A. A failed reach is never presented as a failed credential. | + +### TASK-C — request a reset link + +| State | Kind | Trigger | Strategy | +|---|---|---|---| +| happy | happy | reset branch entered | The identifier already typed is carried in. The user is not asked to retype something they just typed. | +| loading | non-happy | request submitted | As TASK-A: busy, non-repeatable, nothing appears discarded. | +| error (identifier malformed) | non-happy | submit with an unusable identifier | The only error this branch may show. **A well-formed identifier always succeeds**, whether or not it belongs to an account — anything else is an enumeration oracle. | +| success (acknowledged) | happy | request accepted | Names the identifier the link went to, so a typo is visible without checking a mailbox. Offers a repeat and a route back. | +| error (repeat throttled) | non-happy | repeat requested too soon | The repeat affordance states the wait in visible copy. A control that silently stops working reads as broken. | +| offline | non-happy | submit with no connectivity | As TASK-A. | + +## Edge-case matrix + +| | loading | empty | error | interrupted | offline | permission-denied | +|---|---|---|---|---|---|---| +| TASK-A | ✅ | n/a — inert submit prevents an empty attempt | ✅ (via TASK-B) | ✅ | ✅ | n/a — no OS permission in this area | +| TASK-B | ✅ | n/a — as above | ✅ ×2 (rejected, locked) | ✅ | ✅ | n/a | +| TASK-C | ✅ | n/a — inert submit prevents an empty request | ✅ ×2 (malformed, throttled) | ✅ | ✅ | n/a | + +Every `n/a` names why. An unexplained `n/a` is indistinguishable from an omission. + +## Accessibility strategy + +- **Interactive target floor: 44 × 44 CSS px of hit area.** Ruled at the Direction Approval Gate (`o-s4`, 2026-08-07) and set in `toolkit.config.json` → `audit.tapTargetFloorPx`. This is **stricter than WCAG 2.2 AA's 24 × 24** and matches the AAA criterion — deliberately. STATE 08 enforces this number, so an element conforming to AA can still fail this audit. **Hit area, not box:** an expanded target counts, and a box smaller than its hit area is a known audit false-positive class, not a defect. +- **Contrast:** 4.5:1 for load-bearing copy, 3:1 at ≥ 18.66px. Decided at STATE 06 on the token pair, with ratios written down — not discovered at audit time. +- **Focus order:** identifier → secret → submit → reset route. Focus is moved deliberately exactly once, on a rejected attempt, and it moves to the field the user has to change. +- **Announcement:** every state change that is not visible where the user is looking is announced through an assertive live region. **Ordering of announcement against focus movement is an open decision** (`o-s5`) — the two can interrupt each other, and the ordering is STATE 06's to rule and STATE 08's to verify. +- **Keyboard reachability:** every affordance is reachable and operable without a pointer. Nothing is reachable only by gesture. +- **Motion opt-out:** every motion has a static equivalent under `prefers-reduced-motion`. **No state is signalled by motion alone** — the rejected-attempt shake carries no information the announced message does not. +- **Script and locale:** single locale, Latin, LTR. `product.scripts` is empty, which turns the per-glyph font check **off**. Correct for this product and wrong for any product rendering a second script — recorded so the emptiness reads as a decision. +- **Text size:** the secret and identifier fields render at ≥ 16px, because below that a mobile browser zooms the viewport on focus and the user loses their place. +- **Paste is not blocked** anywhere, including on the secret. Blocking it removes the mechanism WCAG 2.2 SC 3.3.8 relies on (research T5, D-s4). + +## UX risks + +- **U1: The rejected-attempt state does two things at once** — announce, and move focus. Done naively they interrupt each other and the user hears neither cleanly. Threatens AC2.2 and AC2.3 together. **Planned response:** rule the ordering at STATE 06 (`o-s5`), verify it at STATE 08 rather than assuming it. +- **U2: The locked state is the one users hit when they are already frustrated**, and it is the state most likely to be built last and reviewed least. Threatens AC6.1 and AC6.2. **Planned response:** it ships with its own deep-link hook like every other state, so it is reachable in one step at the review gate rather than after five deliberate failures. +- **U3: "Simulated" is easy to claim and easy to get wrong.** If any part of this area issues a real request, the whole non-goal collapses quietly. **Planned response:** STATE 08 sweeps the source for network call sites rather than trusting the claim. +- **U4: The reset branch's error space is deliberately tiny**, and the pressure during build will be to add a helpful "we don't recognise that address". Threatens AC4.3 outright. **Planned response:** AC4.3 is falsifiable and marked with evidence at STATE 08; the single acknowledgement state is the design that makes the failure hard to build. + +## Open decisions + +- **`o-s1`:** Lockout threshold and window — assumed 5 attempts / 15 minutes. Blocks nothing; occupies one string. Ruled by: whoever owns the auth service. +- **`o-s2`:** Is a way to reveal the typed secret in scope? The design system has no primitive for it, so it is an Extension Note at STATE 06, not a free addition. Ruled by: product. +- **`o-s3`:** Copy ownership. Every string in this area is placeholder and unreviewed. Ruled by: content design. +- **`o-s5`:** Announcement-versus-focus ordering on a rejected attempt (U1). Ruled by: STATE 06. Verified by: STATE 08. + +_`o-s4` was closed at the Direction Approval Gate — 44px holds._ + +## Validation self-check + +- **V1** ✅ — TASK-A enumerates happy + 4 non-happy (loading, offline, interrupted, + error via TASK-B); TASK-B happy + 4; TASK-C happy + 5. All three exceed the ≥ 3 floor. +- **V2** ✅ — accessibility strategy present, non-empty, and states its target floor as a number. +- **V3** ✅ — TASK-A → R1/R3/R7, TASK-B → R2/R3/R6/R7, TASK-C → R4/R5/R6/R7. All prioritized. +- **V4** ✅ — no screen id, no layout, no component name, no colour value appears in this document. + +**Exit:** validation passes → `FLOW_GENERATION`. diff --git a/design-toolkit/examples/signin/reference/audit-plan.json b/design-toolkit/examples/signin/reference/audit-plan.json new file mode 100644 index 0000000..70d0e1a --- /dev/null +++ b/design-toolkit/examples/signin/reference/audit-plan.json @@ -0,0 +1,25 @@ +{ + "_note": "STATE 08 — what tools/audit.mjs drives. Eleven rows: one per flow state in flow-signin-01. Two passes: base, and reduced-motion. `id` is present only on the three rows that ARE a screen's happy state, because the `id matches registry` assertion compares the page's printed data-sid to this field — a state row and a screen row are different claims.", + + "passes": [ + { "name": "base", "query": "" }, + { "name": "reduced-motion", "query": "rm=1" } + ], + + "screens": [ + { "id": "S-SIGN-01", "label": "S-SIGN-01 happy", "url": "signin.html?view=signin" }, + { "id": "S-SIGN-02", "label": "S-SIGN-02 happy", "url": "signin.html?view=reset" }, + { "id": "S-SIGN-03", "label": "S-SIGN-03 happy", "url": "signin.html?view=sent" } + ], + + "states": [ + { "label": "S-SIGN-01 loading", "url": "signin.html?view=signin&state=loading" }, + { "label": "S-SIGN-01 invalid-credentials", "url": "signin.html?view=signin&state=invalid" }, + { "label": "S-SIGN-01 locked-rate-limited", "url": "signin.html?view=signin&state=locked" }, + { "label": "S-SIGN-01 offline", "url": "signin.html?view=signin&state=offline" }, + { "label": "S-SIGN-02 loading", "url": "signin.html?view=reset&state=loading" }, + { "label": "S-SIGN-02 invalid-email", "url": "signin.html?view=reset&state=bademail" }, + { "label": "S-SIGN-03 success-resent", "url": "signin.html?view=sent&state=resent" }, + { "label": "S-SIGN-03 resend-throttled", "url": "signin.html?view=sent&state=throttled" } + ] +} diff --git a/design-toolkit/examples/signin/reference/design-system.md b/design-toolkit/examples/signin/reference/design-system.md new file mode 100644 index 0000000..5de6119 --- /dev/null +++ b/design-toolkit/examples/signin/reference/design-system.md @@ -0,0 +1,126 @@ +# Toolkit Reference DS + +**Source id:** `toolkit-ref-ds-0.1` +**Platform:** mobile web · **Base viewport:** 393 × 852 +**Owner:** this reference run. Not a real product design system. + +> **Why this file exists.** STATE 06 requires a design system named **by source id**, because a plan built on the wrong design system validates perfectly against it — one such mix-up survived four revision cycles and reached `HALT_BLOCKED` before the tell was spotted (a desktop-first viewport assumption inside a mobile product). +> +> A reference run with no design system would skip the rule it most needs to demonstrate. So this is a real, minimal system, and its own assumptions are stated so they can be checked against the product's: **mobile, 393 × 852, single-script, light theme only.** + +--- + +## Tokens + +### Colour + +| Token | Value | Use | +|---|---|---| +| `--ink` | `#141821` | Primary text | +| `--ink-2` | `#5A6274` | Secondary text, helper copy | +| `--ink-3` | `#8A93A6` | Placeholder, disabled label | +| `--bg` | `#FFFFFF` | Screen background | +| `--surface` | `#F4F6FA` | Inset surface, field background | +| `--line` | `#DDE2EC` | Hairline border | +| `--brand` | `#2B3FD6` | Primary action, focus ring, links | +| `--brand-ink` | `#FFFFFF` | Text on `--brand` | +| `--brand-weak` | `#EEF1FE` | Brand-tinted surface | +| `--danger` | `#B3261E` | Error text and error border | +| `--danger-weak` | `#FDECEA` | Error banner background | +| `--ok` | `#1B6E3C` | Success text | +| `--ok-weak` | `#E7F4EC` | Success banner background | + +**Audited pairs** — decided here, on the token pair, not at audit time. + +| Foreground | Background | Ratio | Needs | Verdict | +|---|---|---|---|---| +| `--ink` `#141821` | `--bg` `#FFFFFF` | **17.8:1** | 4.5 | pass | +| `--ink-2` `#5A6274` | `--bg` `#FFFFFF` | **6.1:1** | 4.5 | pass | +| `--ink-3` `#8A93A6` | `--bg` `#FFFFFF` | **3.1:1** | 4.5 | **fail — placeholder only, never load-bearing text** | +| `--brand-ink` `#FFFFFF` | `--brand` `#2B3FD6` | **7.5:1** | 4.5 | pass | +| `--brand` `#2B3FD6` | `--bg` `#FFFFFF` | **7.5:1** | 4.5 | pass | +| `--danger` `#B3261E` | `--bg` `#FFFFFF` | **6.5:1** | 4.5 | pass | +| `--danger` `#B3261E` | `--danger-weak` `#FDECEA` | **5.7:1** | 4.5 | pass | +| `--ok` `#1B6E3C` | `--ok-weak` `#E7F4EC` | **5.5:1** | 4.5 | pass | + +Ratios computed from the sRGB relative-luminance formula, not estimated. They are written down here so a pairing that fails is a **plan** change rather than a prototype patch discovered at audit time. + +`--ink-3` fails 4.5:1 by design and is **restricted to placeholder text**, which is not the accessible name of any field. A plan that puts real copy on `--ink-3` is a plan change, not a prototype patch. + +### Spacing + +4pt base. `--sp-1` 4 · `--sp-2` 8 · `--sp-3` 12 · `--sp-4` 16 · `--sp-5` 24 · `--sp-6` 32 · `--sp-7` 48 + +### Typography + +Base stack is set on the container, never per component — the token layer is the **base**, not an opt-in. + +```css +--ui-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +``` + +| Token | Size / line | Weight | Use | +|---|---|---|---| +| `--t-display` | 28 / 34 | 700 | Screen title | +| `--t-body` | 16 / 24 | 400 | Body, field text | +| `--t-label` | 14 / 20 | 600 | Field label | +| `--t-small` | 13 / 18 | 400 | Helper, error copy | + +16px is the minimum field font size: below it, mobile Safari zooms on focus. + +### Radius and elevation + +`--r-field` 12 · `--r-btn` 12 · `--r-card` 16 · `--focus-ring` `0 0 0 3px rgba(43,63,214,.35)` + +### Motion + +| Token | Value | Reduced-motion equivalent | +|---|---|---| +| `--m-fast` | 120ms ease-out | none — apply the end state immediately | +| `--m-base` | 200ms ease-out | none | +| `--m-shake` | 300ms shake keyframe | none — the error banner alone carries the signal | + +Every motion token has a static equivalent. Motion is never the sole carrier of meaning. + +--- + +## Primitives + +| Primitive | Variants | Notes | +|---|---|---| +| `ds-field` | `default` · `error` · `disabled` | Label + input + helper slot. Min height **48px**. Error variant swaps border to `--danger` and reveals the helper slot with `role="alert"`. | +| `ds-button` | `primary` · `ghost` · `link` | Min height **48px**, min width 48px. `primary` fills `--brand`. `loading` state swaps the label for a spinner and sets `aria-busy`. | +| `ds-banner` | `error` · `success` · `info` | Full-width inset surface, icon + copy. `role="alert"` on `error`. | +| `ds-link` | `inline` · `block` | `--brand`, underlined. The `inline` variant expands its hit area to 48px via `::after{inset:-14px}` — the **box** stays smaller than the hit area, which is a known audit false-positive class. The `block` variant is `inline-flex` with `min-height:48px` and needs no expansion. | +| `ds-screen` | — | The 393 × 852 container. Sets `--ui-font` on itself. Safe-area padding via `env(safe-area-inset-*)`. | +| `ds-toolbar` | `back` · `none` | Top bar, 56px, optional back affordance. | + +### Interactive target floor + +**48px**, which exceeds this product's stated acceptance floor of 44px. Stated here so STATE 06 can check its layout numbers against `audit.tapTargetFloorPx` — the last state where the number is free to change. + +--- + +## What this system does not have + +Recorded so STATE 06 raises an **Extension Note** rather than inventing a primitive: + +- No password-visibility toggle primitive. +- No countdown / resend-timer primitive. +- No inline field-level validation timing rule. +- No offline banner variant (`ds-banner` has `error`, not `offline`). + +--- + +## Assumptions this system makes + +Check these against the product before adopting it — this list is the tell that catches a wrong-document adoption: + +| Assumption | Value | +|---|---| +| Platform | mobile web | +| Viewport | 393 × 852 | +| Theme | light only | +| Scripts | Latin only | +| Direction | LTR only | +| Density | single (no compact variant) | diff --git a/design-toolkit/examples/signin/reference/edge-annotations.json b/design-toolkit/examples/signin/reference/edge-annotations.json new file mode 100644 index 0000000..7284921 --- /dev/null +++ b/design-toolkit/examples/signin/reference/edge-annotations.json @@ -0,0 +1,83 @@ +{ + "_note": "STATE 12 / E6. Four fields per edge, two per frame. The edge set is navgraph's — this file may not add or drop one. `nav` and `guard` are AUTHORED WITH EVIDENCE because they live in the control flow; `anim` and `api` are DERIVED and re-swept every run. UNKNOWN is a legal value and a guessed value is not: an `api` field filled with a plausible endpoint is worse than an empty one, because the developer will build it.", + "_prototype": "proto-signin-02 · artifacts/prototype/signin.html", + "_api_column": "Every value reads `none (simulated)`. That is the most useful thing this layer says to a receiving team, and it is only worth reading because tools/annotate.mjs re-greps for fetch / XMLHttpRequest / WebSocket / sendBeacon / EventSource on every run and fails (E11) if the column and the sweep disagree.", + + "edges": [ + { + "from": "S-SIGN-01", + "to": "S-SIGN-02", + "label": "forgot password", + "nav": { + "value": "push", + "source": "signin.html:348", + "note": "Push, not replace — back must return the user to credential entry with what they typed intact (ux-signin-01 navigation model). The handler copies f-email into f-reset-email before the view change (AC4.2)." + }, + "anim": { "value": "fade 200ms ease-out", "source": "signin.html:74" }, + "api": { "value": "none (simulated)", "source": "re-run network sweep 2026-08-07 — 0 call sites in signin.html" }, + "guard": { + "value": "none — the reset route is live from every state of S-SIGN-01, including locked{rate-limited}", + "source": "signin.html:348", + "note": "Deliberate: being locked out is the strongest reason to want the reset branch." + } + }, + { + "from": "S-SIGN-02", + "to": "S-SIGN-01", + "label": "back", + "nav": { + "value": "back", + "source": "signin.html:354", + "note": "Toolbar back. Correct on this screen because it was reached by push." + }, + "anim": { "value": "fade 200ms ease-out", "source": "signin.html:74" }, + "api": { "value": "none (simulated)", "source": "re-run network sweep 2026-08-07 — 0 call sites in signin.html" }, + "guard": { "value": "none — unconditional", "source": "signin.html:354" } + }, + { + "from": "S-SIGN-02", + "to": "S-SIGN-03", + "label": "request accepted", + "nav": { + "value": "replace", + "source": "signin.html:359", + "note": "REPLACE, not push (D-f3). Back into a request already submitted invites a duplicate, which is why S-SIGN-03 carries its own explicit route out and no toolbar back — the toolbar it had in proto-signin-01 was superseded by rev-signin-01 / CR3." + }, + "anim": { "value": "fade 200ms ease-out", "source": "signin.html:74" }, + "api": { "value": "none (simulated)", "source": "re-run network sweep 2026-08-07 — 0 call sites in signin.html" }, + "guard": { + "value": "D4 wellFormed(identifier) AND D5 reachable", + "source": "flows-signin.md D4 · flows-signin.md D5 · signin.html:359", + "note": "There is NO account-exists term in this guard, by design (D-f2). A well-formed identifier always lands here whether or not it belongs to an account — adding the term would build an enumeration oracle." + } + }, + { + "from": "S-SIGN-03", + "to": "S-SIGN-01", + "label": "back to sign in", + "nav": { + "value": "back", + "source": "signin.html:358", + "note": "An explicit in-screen route, not browser back — this screen was reached by replace. It is the ONLY way out of S-SIGN-03 after CR3." + }, + "anim": { "value": "fade 200ms ease-out", "source": "signin.html:74" }, + "api": { "value": "none (simulated)", "source": "re-run network sweep 2026-08-07 — 0 call sites in signin.html" }, + "guard": { "value": "none — unconditional", "source": "signin.html:358" } + } + ], + + "frames": { + "S-SIGN-01": { + "auth": { "value": "guest-ok", "source": "req-signin-01 non-goals — the entire unauthenticated area is reachable without a session" }, + "perm": { "value": "none", "source": "ux-signin-01 edge-case matrix — permission-denied marked n/a with a reason: no OS permission is required anywhere in this area" } + }, + "S-SIGN-02": { + "auth": { "value": "guest-ok", "source": "req-signin-01 non-goals" }, + "perm": { "value": "none", "source": "ux-signin-01 edge-case matrix" } + }, + "S-SIGN-03": { + "auth": { "value": "guest-ok", "source": "req-signin-01 non-goals" }, + "perm": { "value": "none", "source": "ux-signin-01 edge-case matrix" } + } + } +} diff --git a/design-toolkit/examples/signin/reference/nav-lanes.json b/design-toolkit/examples/signin/reference/nav-lanes.json new file mode 100644 index 0000000..336d7e8 --- /dev/null +++ b/design-toolkit/examples/signin/reference/nav-lanes.json @@ -0,0 +1,11 @@ +{ + "_note": "Swimlane assignment for STATE 12 (E1). NODE lanes only — edge-level system/api attribution lives in the E6 annotation columns, not here. A screen with no lane is reported by tools/navgraph.mjs as N8 and never guessed into a lane: a wrong lane reads as a ruling about who owns a screen.", + "_degenerate_case": "Single-actor product. Every screen is `customer`; admin, system and api are declared and empty. That is the honest shape, not a gap — a lane file with three empty lanes says 'checked, nothing here', and a missing lane file says nothing at all.", + "order": ["customer", "admin", "system", "api"], + "lanes": { + "customer": ["S-SIGN-01", "S-SIGN-02", "S-SIGN-03"], + "admin": [], + "system": [], + "api": [] + } +} diff --git a/design-toolkit/examples/signin/reference/screen-registry.csv b/design-toolkit/examples/signin/reference/screen-registry.csv new file mode 100644 index 0000000..f651822 --- /dev/null +++ b/design-toolkit/examples/signin/reference/screen-registry.csv @@ -0,0 +1,4 @@ +screen_id,flow,screen_name,purpose,data_content,key_components,states,entry_from,navigates_to,status,notes +S-SIGN-01,01 Sign In,Sign in,Credential entry and in-place recovery from a rejected attempt,"identifier + secret the user types; nothing is fetched","ds-screen, ds-field x2, ds-button primary, ds-link block, ds-banner error","happy, loading, error{invalid-credentials}, locked{rate-limited}, offline",app launch | S-SIGN-02 | S-SIGN-03,S-SIGN-02 (forgot password),designed,"Entry point of the unauthenticated area. Success leaves this flow into the authenticated area — a boundary, not a route in this registry. proto-signin-01." +S-SIGN-02,01 Sign In,Reset password,Request a reset link for an identifier,"identifier carried forward from S-SIGN-01 when present","ds-screen, ds-toolbar back, ds-field, ds-button primary, ds-banner error","happy, loading, error{invalid-email}",S-SIGN-01,S-SIGN-01 (back) | S-SIGN-03 (request accepted),designed,"No account-exists branch by design (D-f2) — a well-formed identifier always reaches S-SIGN-03. proto-signin-01." +S-SIGN-03,01 Sign In,Check your email,Acknowledge the request and offer a throttled repeat,"the identifier the link was sent to","ds-screen, ds-toolbar back, ds-button ghost, ds-link block, ds-banner success","happy, success{resent}, error{resend-throttled}",S-SIGN-02,S-SIGN-01 (back to sign in),designed,"Reached by REPLACE, not push (D-f3) — hence its own explicit route back. proto-signin-01." diff --git a/design-toolkit/examples/signin/reference/state-machines.json b/design-toolkit/examples/signin/reference/state-machines.json new file mode 100644 index 0000000..817ebff --- /dev/null +++ b/design-toolkit/examples/signin/reference/state-machines.json @@ -0,0 +1,63 @@ +{ + "_note": "STATE 12 / E5. The node set is DERIVED from reference/screen-registry.csv — this file may not add or drop a state, and tools/stategraph.mjs fails the run if it does. The edge set is AUTHORED WITH EVIDENCE: every transition cites a file:line into the frozen bytes, and the tool resolves the citation. An unevidenced arrow looks like a spec and is a guess.", + "_prototype": "proto-signin-02 · artifacts/prototype/signin.html", + + "screens": { + "S-SIGN-01": { + "initial": "happy", + "note": "Entry point of the unauthenticated area. Success leaves this flow into ⟂HOME-01, which is a boundary rather than a state of this screen.", + "states": { + "happy": { "hook": "signin.html?view=signin", "note": "Default entry. Submit is inert until both fields carry content (D1 is a guard on the affordance, not a branch)." }, + "loading": { "hook": "signin.html?view=signin&state=loading", "note": "aria-busy, label swapped, second submit impossible." }, + "error{invalid-credentials}": { "hook": "signin.html?view=signin&state=invalid", "note": "Identifier preserved, focus on the secret, announced via a role=alert container that pre-exists in the DOM (o-s5)." }, + "locked{rate-limited}": { "hook": "signin.html?view=signin&state=locked", "terminal": true, "note": "TERMINAL AT SCREEN LEVEL, not a dead end. No in-screen transition changes it — the window elapses off-screen, and the way out is the reset route, which leaves for S-SIGN-02. That cross-screen edge is navgraph's to draw, not this machine's. The banner renders the ASSUMED 15-minute window (o-s1, debt #1). Flagged rather than left to report as S7 every run: a suppressed advisory teaches a reader to skim." }, + "offline": { "hook": "signin.html?view=signin&state=offline", "note": "Distinguished from a rejected credential by copy only — ds-banner has no offline variant (Extension Note 1)." } + }, + "transitions": [ + { "from": "happy", "to": "loading", "trigger": "submit with both fields non-empty (D1, D2)", "kind": "user", "evidence": "signin.html:364" }, + { "from": "error{invalid-credentials}", "to": "loading", "trigger": "correct the secret and resubmit", "kind": "user", "evidence": "signin.html:364" }, + { "from": "offline", "to": "loading", "trigger": "retry", "kind": "user", "evidence": "signin.html:364" }, + { "from": "loading", "to": "error{invalid-credentials}", "trigger": "D3 no, attempts < N", "kind": "system", "evidence": "signin.html:299" }, + { "from": "loading", "to": "locked{rate-limited}", "trigger": "D3 no, attempts >= N", "kind": "system", "evidence": "signin.html:299" }, + { "from": "loading", "to": "offline", "trigger": "D2 no — request did not reach the service", "kind": "system", "evidence": "signin.html:299" }, + { "from": "happy", "to": "error{invalid-credentials}", "trigger": "entry on a URL hook — QA and review path", "kind": "entry", "evidence": "signin.html:367" }, + { "from": "happy", "to": "locked{rate-limited}", "trigger": "entry on a URL hook — QA and review path", "kind": "entry", "evidence": "signin.html:367" }, + { "from": "happy", "to": "offline", "trigger": "entry on a URL hook — QA and review path", "kind": "entry", "evidence": "signin.html:367" } + ] + }, + + "S-SIGN-02": { + "initial": "happy", + "note": "Reached by PUSH from S-SIGN-01, so its toolbar back is correct. Carries the identifier already typed (AC4.2).", + "states": { + "happy": { "hook": "signin.html?view=reset", "note": "Identifier pre-filled from S-SIGN-01 when one was typed." }, + "loading": { "hook": "signin.html?view=reset&state=loading", "note": "aria-busy on the send control." }, + "error{invalid-email}": { "hook": "signin.html?view=reset&state=bademail", "note": "The ONLY error this branch may show. There is deliberately no account-exists branch (D-f2) — adding one would build an enumeration oracle." } + }, + "transitions": [ + { "from": "happy", "to": "loading", "trigger": "submit with a well-formed identifier (D4 yes)", "kind": "user", "evidence": "signin.html:359" }, + { "from": "happy", "to": "error{invalid-email}", "trigger": "submit with a malformed identifier (D4 no)", "kind": "user", "evidence": "signin.html:303" }, + { "from": "error{invalid-email}", "to": "loading", "trigger": "correct the identifier and resubmit", "kind": "user", "evidence": "signin.html:359" }, + { "from": "loading", "to": "error{invalid-email}", "trigger": "D5 no — request did not reach the service", "kind": "system", "evidence": "signin.html:304" } + ] + }, + + "S-SIGN-03": { + "initial": "happy", + "note": "Reached by REPLACE (D-f3), which is why it carries an explicit route back instead of relying on browser back. The toolbar back that proto-signin-01 had here was superseded by rev-signin-01 / CR3.", + "states": { + "happy": { "hook": "signin.html?view=sent", "note": "Names the address the link was sent to (AC5.1)." }, + "success{resent}": { "hook": "signin.html?view=sent&state=resent", "note": "role=status rather than role=alert — a confirmation should not interrupt." }, + "error{resend-throttled}": { "hook": "signin.html?view=sent&state=throttled", "note": "The resend control is DISABLED here, not merely captioned. rev-signin-01 / CR2 — copy is not the mechanism." } + }, + "transitions": [ + { "from": "happy", "to": "success{resent}", "trigger": "resend outside the throttle window (D6 no)", "kind": "user", "evidence": "signin.html:365" }, + { "from": "success{resent}", "to": "success{resent}", "trigger": "resend again outside the window", "kind": "user", "evidence": "signin.html:365" }, + { "from": "happy", "to": "error{resend-throttled}", "trigger": "resend inside the throttle window (D6 yes)", "kind": "user", "evidence": "signin.html:317" }, + { "from": "error{resend-throttled}", "to": "happy", "trigger": "the window elapses", "kind": "system", "evidence": "signin.html:317" } + ] + } + }, + + "id_conflicts": [] +} diff --git a/design-toolkit/examples/signin/state/machine_state.yaml b/design-toolkit/examples/signin/state/machine_state.yaml new file mode 100644 index 0000000..dde280d --- /dev/null +++ b/design-toolkit/examples/signin/state/machine_state.yaml @@ -0,0 +1,112 @@ +# signin — global machine state +# +# This file is NOT an artifact. It is the machine's own record, and §8 completion +# rule 1 is defined over it. Written AT DECISION TIME, in the same edit as the +# thing it records. +# +# Side effect worth knowing: artifacts/prototype/serve.py gates live reload on the +# top-level `current_state` being USER_REVIEW (skills/09 G1). + +machine_state: + project: signin + current_state: DONE + scope: signin — email/password sign in with a forgotten-password reset path + gate: granted # pending | granted | denied + handoff_required: true # ruled by the user at the Primary gate + blocked_reason: null + + last_transition: + from: FINAL_OUTPUT + to: DONE + trigger: "freeze complete — all 6 completion rules hold, 0 waivers; user approved 'Approve' + 'Extend previous approval' + 'Approve Developer Handoff'" + ts: "2026-08-07" + + # Per-state visit counter, for retry ceilings (C_RETRY_OK) + entry_count: + REQUIREMENT_ANALYSIS: 1 + RESEARCH: 1 + PRODUCT_REVIEW: 1 + UX_PLANNING: 1 + FLOW_GENERATION: 1 + UI_PLANNING: 2 # +1 from rev-signin-01 CR3 — root cause was here, not in PROTOTYPE + PROTOTYPE: 2 # proto-signin-01, proto-signin-02 + SELF_AUDIT: 2 # audit-signin-01 (fail), audit-signin-02 (pass) + REVISION: 1 + USER_REVIEW: 1 + FLOW_VISUALIZATION: 1 + FINAL_OUTPUT: 1 + + # Per-loop iteration counter (C_LOOP_OK). Ceilings live in toolkit.config.json. + loop_count: + L_CLARIFY: 0 # never entered — no blocking open question was raised + L_RESEARCH: 0 + L_UX_EDGE: 0 + L_REVISION: 1 # of 3 — one full cycle: SELF_AUDIT fail -> REVISION -> UI_PLANNING+PROTOTYPE -> SELF_AUDIT pass + L_AUDIT_FIX: 0 # of 3 — this was a full rebuild, not an in-place audit fix + + # An approval is scoped to the artifact versions it saw. If those change, the + # gate reverts to `pending` — the stale-approval rule (§5). + approvals: + ClarificationGate: pending # never fired — STATE 01 exited with 0 blocking questions, + # three non-blocking items carried as o-s1..o-s3 + DirectionApprovalGate: granted # user, 2026-08-07, scoped to pr-signin-01; ruled o-s4 = hold 44px + PrimaryUserApprovalGate: granted # user, 2026-08-07, reviewed proto-signin-02, + # re-scoped to proto-signin-03 on a bug-fix-only scope confirm + ConflictMiniGate: pending + DeveloperHandoffGate: granted # user, 2026-08-07, on the report + 4 clean exit codes + + artifact_versions: + requirements-signin.md: req-signin-01 + research-signin.md: res-signin-01 + product-review-signin.md: pr-signin-01 + ux-plan-signin.md: ux-signin-01 + flows-signin.md: flow-signin-01 + ui-plan-signin.md: ui-signin-02 # supersedes ui-signin-01 (rev CR3) + prototype: proto-signin-03 # supersedes proto-signin-02 (ratified bug-fix delta) + traceability-signin.md: trace-signin-02 + audit-report-signin.md: audit-signin-03 # supersedes -02; -01 (fail) preserved as a separate file + review-record-signin.md: review-signin-01 + flow-visualization-signin.md: navmap-signin-01 + revision-log-signin.md: rev-signin-01 + + # Open decisions carried forward rather than defaulted (skills/04, skills/05). + opens: + o-s1: "lockout threshold + window — assumed 5 / 15 min · ruled by auth service owner" + o-s2: "CLOSED at STATE 06 — out of scope this cycle; recorded as Extension Note 3" + o-s3: "copy ownership — every string is placeholder · ships as a known limitation" + o-s4: "CLOSED at the Direction Gate 2026-08-07 — hold 44px" + o-s5: "CLOSED — ruled at STATE 06, verified by audit-signin-02 AC2.2 + AC2.3" + + # One row per feature. `deliverable` names the folder the freeze landed in — + # `designed` and `delivered` are different claims (skills/11 P2). + flows: + - id: signin + state: DONE + gate: granted + prototype: proto-signin-03 + audit: audit-signin-03 + deliverable: artifacts/deliverable-signin/ # `designed` and `delivered` are different claims + passes_to_approval: 1 + + # sha256 per frozen file. A freeze is a hash, not a copy (skills/11 P2). + freeze: + "deliverable-signin/prototype/signin.html": "ac31e27100c8c729a1173ee6252ada8f8663ecd46e6a4e15c2c130aa163eb56f" + "deliverable-signin/prototype/play.html": "3277901ea9f3365ca6a068e6a60588683254b4e940f04c46f0c852f462828462" + "deliverable-signin/prototype/serve.py": "11c6df40390bbd05ec88d0051d6a78ccc7e2b5a34f3756f3da418ed69d0c8d15" + "deliverable-signin/prototype/run-local.sh": "02173bbd3c1a9fa1c7401fe4f5a316e02c2e970070389f05d3d42e047d50eabb" + + # Tracked debt. Every waiver rides on a numbered item here (skills/09 G6). + debt: + 1: "locked{rate-limited} renders the assumed 15-min window as fact (o-s1). Closes when the auth service owner states the real window." + + # ---- §8 Completion Rules ---- + # Each gets a boolean AND a one-line reason. `true` with no reason is the + # silence skills/11 P6 forbids. + completion_check: + C1_state_is_done: true # written in this same edit as the freeze, not reconstructed later + C2_approval_scoped_to_final_frozen_versions: true # review-signin-01 scoped_to proto-signin-03 = the frozen sha ac31e271... + C3_all_criteria_met: true # trace-signin-02 + audit-signin-03: 26 met / 0 waived / 0 superseded / 0 unmet + C4_audit_pass_on_final_version: true # audit-signin-03 ran on proto-signin-03, the bytes in `freeze` + C5_deliverables_complete: true # deliverable-signin/ holds the frozen prototype + handoff-signin.md + C6_no_open_revision_items: true # rev-signin-01: 4 resolved, 1 deferred with a reason + debt id, 0 open + C7_handoff_gate_and_navmap_clean: true # navmap-signin-01 granted; 4 validators exit 0 at --fail-on major, 0 waivers diff --git a/design-toolkit/examples/signin/toolkit.config.json b/design-toolkit/examples/signin/toolkit.config.json new file mode 100644 index 0000000..4077f86 --- /dev/null +++ b/design-toolkit/examples/signin/toolkit.config.json @@ -0,0 +1,85 @@ +{ + "$schema": "../../tools/config.schema.json", + + "product": { + "name": "Sign In — toolkit reference run", + "slug": "signin", + "platform": "mobile", + "viewport": { "width": 393, "height": 852 }, + "locales": ["en"], + "scripts": [], + "note": "Single-script, single-locale on purpose. `scripts` empty means the per-glyph font check is OFF — correct here, and the honest degenerate case worth seeing in an example." + }, + + "paths": { + "artifacts": "artifacts", + "reference": "reference", + "prototype": "artifacts/prototype", + "shots": "artifacts/shots", + "state": "state/machine_state.yaml", + "registry": "reference/screen-registry.csv", + "lanes": "reference/nav-lanes.json", + "stateMachines": "reference/state-machines.json", + "vocabulary": "reference/state-vocabulary.md", + "edgeAnnotations": "reference/edge-annotations.json" + }, + + "screens": { + "idPattern": "^S-[A-Z0-9]+-[0-9]+[A-Z]?$", + "flowSectionFormat": "FLOW-XXX • Journey Name" + }, + + "review": { + "port": 8765, + "player": "play.html", + "liveReloadWhenState": "USER_REVIEW" + }, + + "prototype": { + "viewSelector": ".view", + "activeClass": "active", + "screenSelector": ".screen", + "sidSelector": "#sid", + "minVisibleNodes": 3 + }, + + "audit": { + "chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "debugPort": 9333, + "servePort": 8791, + "tapTargetFloorPx": 44, + "colorAllowlist": [ + "#2B3FD6", "#EEF1FE", "#FFFFFF", + "#141821", "#5A6274", "#8A93A6", "#F4F6FA", "#DDE2EC", + "#B3261E", "#FDECEA", "#1B6E3C", "#E7F4EC" + ], + "colorBanned": ["#FF0000", "#0000FF", "#000000", "#CCCCCC"], + "paletteExemptSelectors": [".demo", ".phone"], + "benignConsole": ["favicon.ico"], + "note": "Written by STATE 06 (ui-signin-01) in the same edit as the plan, and enforced by STATE 08. An allowlist that lives only in the plan document is an allowlist nothing enforces. tapTargetFloorPx stays 44 per the o-s4 ruling at the Direction Approval Gate — stricter than WCAG 2.2 AA's 24px, deliberately." + }, + + "flowPages": { + "SIGN": "signin.html", + "_note": "Screen-id prefix SIGN would default to sign.html; the page is signin.html, so the mapping is declared. A prefix with no page is reported as N9, never silently skipped." + }, + + "designSystem": { + "name": "Toolkit Reference DS", + "sourceId": "toolkit-ref-ds-0.1 · reference/design-system.md", + "note": "Named BY SOURCE ID per skills/06 failure mode #1. A plan built on the wrong design system validates perfectly against it." + }, + + "figma": { + "fileKey": "", + "pages": {} + }, + + "loops": { + "L_CLARIFY": 3, + "L_RESEARCH": 2, + "L_UX_EDGE": 2, + "L_REVISION": 3, + "L_AUDIT_FIX": 3 + } +} diff --git a/design-toolkit/reference/.gitkeep b/design-toolkit/reference/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/design-toolkit/reference/README.md b/design-toolkit/reference/README.md new file mode 100644 index 0000000..e2188c9 --- /dev/null +++ b/design-toolkit/reference/README.md @@ -0,0 +1,114 @@ +# `reference/` — product-owned inputs + +[← Repository root](../README.md) · [Artifact Flow](../ARTIFACT_FLOW.md) · [Validation Engine](../VALIDATION_ENGINE.md) + +--- + +## Purpose + +Product-owned inputs. **Not** produced by the pipeline — the pipeline reads them. + +Everything in [`artifacts/`](../artifacts/) is written by a state. Everything here is written by you, or by a state acting on your product's behalf, and it persists across runs. + +| File | Owner | Seed from | Read by | +|---|---|---|---| +| `screen-registry.csv` | the product; rows added as flows are designed | [`templates/screen-registry.csv`](../templates/screen-registry.csv) | `navgraph.mjs`, `stategraph.mjs`, STATE 12 | +| `nav-lanes.json` | STATE 12 (E1) | [`templates/nav-lanes.json`](../templates/nav-lanes.json) | `navgraph.mjs` | +| `state-vocabulary.md` | STATE 12 (E5) | [`templates/state-vocabulary.md`](../templates/state-vocabulary.md) | `navgraph.mjs`, `stategraph.mjs` | +| `state-machines.json` | STATE 12 (E5) | [`templates/state-machines.json`](../templates/state-machines.json) | `stategraph.mjs`, `stateprobe.mjs`, `audit.mjs` fallback | +| `edge-annotations.json` | STATE 12 (E6) | [`templates/edge-annotations.json`](../templates/edge-annotations.json) | `annotate.mjs` | +| `audit-plan.json` | STATE 08 (optional) | [`templates/audit-plan.json`](../templates/audit-plan.json) | `audit.mjs` | +| design-system export, brand assets, research sources | the product | — | STATE 06 | + +Paths are declared in [`toolkit.config.json`](../toolkit.config.json) → `paths`; move a file and update that, never a tool. + +## Inputs + +Yours. The registry grows as flows are designed; the lane file, vocabulary, state machines and annotations are authored by STATE 12 as the navigation layer is built; the audit plan is optional and written when you need passes the state machines cannot express. + +## Outputs + +Nothing directly. These files are the **source** the derivations read. `navgraph.json`, `stategraph.json` and `annotations.json` are all downstream of what is in this folder. + +--- + +## The registry is the spine + +`tools/navgraph.mjs` derives the **entire** navigation model from the registry's cells. That has two consequences worth internalising: + +1. **If the diagram and the derivation disagree, the diagram is wrong.** A connector drawn by hand is an assertion nobody can re-check. +2. **A registry cell carrying prose where an id belongs is a finding** (`N10-unparsed`), not a stylistic quibble — it silently drops an edge. + +Two separators, and they are not interchangeable: **`states` is comma-separated** (`happy, error{invalid-input}, loading`) and **`entry_from` / `navigates_to` are pipe-separated** (`S-FLOW-02 | S-OTHER-01 (condition)`). Getting them backwards makes `navgraph` read one cell as a single label — reported as `N11-state-syntax`, not silently swallowed. + +`entry_from` is the column that rots. It gets written when a screen is designed and never updated when a *later* flow starts routing to it. The forward edge (`navigates_to`) is authoritative and the map draws correctly regardless — but `entry_from` is what a developer reads to answer "who can send me here", so it is worth repairing when `N3b-backedge` reports it. + +--- + +## Examples + +**Seeding a new product:** + +```bash +cp templates/screen-registry.csv reference/screen-registry.csv +cp templates/nav-lanes.json reference/nav-lanes.json +cp templates/state-vocabulary.md reference/state-vocabulary.md +``` + +**A registry row:** + +```csv +screen_id,flow,screen_name,purpose,data_content,key_components,states,entry_from,navigates_to,status,notes +S-SIGN-01,01 Sign in,Sign in,Credential entry,"email + password fields","input, button","happy, error{invalid-credentials}, loading",app launch,S-SIGN-02 | S-HOME-01 (success),designed,"" +``` + +**A lane assignment** — a single-actor product is the honest degenerate case, with the other lanes declared and empty: + +```json +{ + "order": ["customer", "admin", "system", "api"], + "lanes": { + "customer": ["S-SIGN-01", "S-SIGN-02", "S-HOME-01"], + "admin": [], + "system": [], + "api": [] + } +} +``` + +**A state label, normalized:** + +``` +error{invalid-credentials} ✓ canon term + qualifier +empty{new-user} ✓ +"empty for a new user" ✗ N11-state-vocab, N11-state-syntax +``` + +**Adding a screen to the registry, then re-deriving:** + +```bash +node tools/navgraph.mjs --fail-on major +# a re-derivation whose edge set differs from the committed one IS the sync signal +``` + +--- + +## Best practices + +- **Add registry rows as flows are designed, not up front.** The *columns* are fixed because the derivation depends on them; the rows follow the work. +- **Fix the registry, not the map.** If a route belongs in the diagram and not in the registry, the registry is what is wrong. +- **Never guess a swimlane.** An unassigned screen is reported as `N8` and left unassigned, because a wrong lane reads as a ruling about who owns a screen. +- **Normalize the state vocabulary before generating anything.** On the extraction run the registry carried **59 distinct free-text labels across 48 screens**, including three spellings of "empty for a new user". Generating first freezes N private vocabularies into a deliverable. Normalized, 58 qualified labels resolved to 12 canon terms with 0 findings — and the originals were preserved in the mapping table, so the rewrite lost nothing. +- **Keep the qualifier; never drop it.** `error{wrong-otp}` and `error{unchecked-terms}` are different screens' different recoveries. +- **Adding a canon term costs a justification**, written into `state-vocabulary.md`. A term only one screen would ever use is a qualifier, not a canon term. Note that the canonical set is carried in both the vocabulary file **and** `tools/navgraph.mjs` / `tools/stategraph.mjs` — adding a term means editing all of them. +- **A boundary status is a dated claim.** Re-derive every port at each sync and record the date it was checked. A status with no date is not a status. +- **`UNKNOWN` is a legal annotation value and a guessed one is not.** An `api` field filled with a plausible endpoint is worse than an empty one, because the developer will build it. +- **Cite, do not restate.** `state-machines.json` transitions and `edge-annotations.json` `nav` / `guard` fields carry `file:line` into the frozen bytes, and the tools resolve them. A citation that no longer lands is a finding — which is what stops these files from quietly aging. +- **Commit this folder.** It is the input every derivation is checked against. + +## Related + +- [`ARTIFACT_FLOW.md § 6`](../ARTIFACT_FLOW.md#6--reference-inputs--read-but-never-produced) — the reference layer in the pipeline +- [`VALIDATION_ENGINE.md`](../VALIDATION_ENGINE.md) — the finding codes each of these files can produce +- [`WORKFLOW_GUIDE.md § STATE 12`](../WORKFLOW_GUIDE.md#state-12--flow_visualization) — who authors what, and when +- [`templates/`](../templates/) — the seeds diff --git a/design-toolkit/skills/01-requirement-analysis/README.md b/design-toolkit/skills/01-requirement-analysis/README.md new file mode 100644 index 0000000..338950c --- /dev/null +++ b/design-toolkit/skills/01-requirement-analysis/README.md @@ -0,0 +1,27 @@ +# Skill: `requirement-analysis` — STATE 01 + +**Turn an ambiguous request into structured, testable requirements.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 01 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-01--requirement_analysis) + +[← skills/](../README.md) · entry state · next → [02 research](../02-research/README.md) + +| Field | Value | +|---|---| +| Machine state | `REQUIREMENT_ANALYSIS` | +| Reads | `raw_request`, attachments | +| Writes | `requirements-<feature>.md` | +| Depends on | — (entry state) | +| Approval gate | **Clarification Gate** — only when blocking ambiguity exists | +| Retry ceiling | 3 | +| Next states | `RESEARCH` / self-loop / `HALT_BLOCKED` | + +## The rule that matters most here + +Falsifiable acceptance criteria are the product of this state. Everything downstream is checked against them, so a vague one is a check nobody can fail. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/01-requirement-analysis/SKILL.md b/design-toolkit/skills/01-requirement-analysis/SKILL.md new file mode 100644 index 0000000..03b2fc5 --- /dev/null +++ b/design-toolkit/skills/01-requirement-analysis/SKILL.md @@ -0,0 +1,118 @@ +--- +name: requirement-analysis +description: >- + State 01 of the AI Product Design Agent workflow. Converts an ambiguous + product request into a structured, testable requirements artifact and a + normalized problem statement. Use when a raw product brief needs to be parsed + into goals, actors, constraints, non-goals, falsifiable acceptance criteria, + surfaced assumptions, and an open-questions list before any research or design + work begins. Reads raw_request; writes requirements.md. Entry state — no + upstream dependency. Raises the Clarification Gate only when blocking + ambiguity exists. +--- + +# Requirement Analysis (STATE 01) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 01. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests requirement analysis. It +> communicates only through the artifact store (`artifacts/`), never directly +> with other skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | `raw_request` (user prompt/brief), optional attachments, `machine_state` | +| Writes | `artifacts/requirements.md` | +| Depends on | — (entry state) | +| Approval gate | **Clarification Gate** — only when blocking ambiguity exists | +| Retry ceiling | 3, then → `HALT_BLOCKED` | +| Next states | `RESEARCH` (pass) / `REQUIREMENT_ANALYSIS` (self-loop, clarification) / `HALT_BLOCKED` (blocking ambiguity + user unavailable) | + +## Purpose + +Turn an ambiguous product request into a structured, testable requirements +artifact and a normalized problem statement. Nothing downstream should have to +re-interpret the raw brief. + +## Processing steps + +1. Parse `raw_request` into **goals, actors, constraints, non-goals**. +2. Detect ambiguity and missing information; produce an `open_questions` list. +3. Classify **scope** (small / medium / large) and effort tier. +4. Draft **falsifiable acceptance criteria** per requirement. +5. Surface **assumptions** explicitly, each flagged `assumed` vs `confirmed`. +6. If blocking ambiguity exceeds threshold → prepare a clarification set and + raise the **Clarification Gate**. + +## Output — `artifacts/requirements.md` + +Write with structured frontmatter + body so downstream skills and the machine +can validate mechanically. + +```markdown +--- +artifact: requirements +version: <hash-or-incrementing-id> +produced_by: requirement-analysis +scope_class: small | medium | large +effort_tier: <tier> +--- + +## Problem statement +<normalized one-paragraph statement> + +## Goals +- G1: <goal> +- G2: <goal> + +## Actors +- <actor>: <role/need> + +## Constraints +- <constraint> + +## Non-goals +- <explicitly out of scope> + +## Requirements & acceptance criteria +- R1: <requirement> + - AC1.1: <falsifiable, observable pass/fail condition> + - AC1.2: ... + +## Assumptions +- A1 [assumed|confirmed]: <assumption> + +## Open questions +- Q1 [blocking|non-blocking]: <question> +``` + +## Validation rules (machine-checkable on output) + +- **V1:** ≥1 goal AND ≥1 acceptance criterion present. +- **V2:** Every requirement has ≥1 falsifiable acceptance criterion. +- **V3:** No requirement (or assumption) tagged both `assumed` and `confirmed`. +- **V4:** `open_questions` is empty, or every item carries a severity + (`blocking` / `non-blocking`). + +## Exit conditions + +All validation rules pass **AND** no `blocking` open question remains +unresolved — resolved either by a user answer or an explicit assumption +acceptance recorded in the Assumptions section. + +## Failure recovery + +- On validation failure: re-run steps 1–5 with the failed rule injected as a + corrective constraint. Increment `entry_count[REQUIREMENT_ANALYSIS]`. +- If `blocking` open questions exist: raise the **Clarification Gate** (workflow + §5). Self-loop (`L_CLARIFY`, ceiling 3) while awaiting answers. +- Retry ceiling 3 reached, or user unavailable with blocking ambiguity → + `HALT_BLOCKED` (state persisted, resumable). + +## Approval gate + +**Clarification Gate** — fires only when blocking ambiguity exists. No approval +required otherwise. Gate state persists in `machine_state.approvals`; an +approval is scoped to the artifact version it saw. diff --git a/design-toolkit/skills/02-research/README.md b/design-toolkit/skills/02-research/README.md new file mode 100644 index 0000000..b090ef1 --- /dev/null +++ b/design-toolkit/skills/02-research/README.md @@ -0,0 +1,27 @@ +# Skill: `research` — STATE 02 + +**Gather cited evidence — domain, competitors, patterns, constraints.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 02 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-02--research) + +[← skills/](../README.md) · prev ← [01 requirement-analysis](../01-requirement-analysis/README.md) · next → [03 product-review](../03-product-review/README.md) + +| Field | Value | +|---|---| +| Machine state | `RESEARCH` | +| Reads | `requirements-<feature>.md` | +| Writes | `research-<feature>.md` | +| Depends on | requirement-analysis | +| Approval gate | None | +| Retry ceiling | 2 (`L_RESEARCH`) | +| Next states | `PRODUCT_REVIEW` / self-loop / `REQUIREMENT_ANALYSIS` | + +## The rule that matters most here + +Contradictions are **listed, not silently resolved**, and a goal is either mapped to a theme or explicitly marked `no-research-needed`. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/02-research/SKILL.md b/design-toolkit/skills/02-research/SKILL.md new file mode 100644 index 0000000..26e3058 --- /dev/null +++ b/design-toolkit/skills/02-research/SKILL.md @@ -0,0 +1,119 @@ +--- +name: research +description: >- + State 02 of the AI Product Design Agent workflow. Gathers external and + internal evidence — domain knowledge, competitors, interaction patterns, and + technical constraints — that informs downstream product and UX decisions. Use + when a validated requirements artifact exists and design work needs an + evidence base: research questions are derived from requirements, searches fan + out by modality, findings are synthesized into cited themes with + contradictions preserved, and coverage of every goal is tracked to a + threshold. Reads requirements.md; writes research.md. Depends on + requirement-analysis. No approval gate. +--- + +# Research (STATE 02) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 02. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests research. It communicates +> only through the artifact store (`artifacts/`), never directly with other +> skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | `artifacts/requirements.md`, `machine_state`, optional research scope config | +| Writes | `artifacts/research.md` | +| Depends on | `requirement-analysis` (must precede) | +| Approval gate | None | +| Retry ceiling | 2 (`L_RESEARCH`), then continue with logged `gap` | +| Next states | `PRODUCT_REVIEW` (pass) / `RESEARCH` (self-loop, coverage gap) / `REQUIREMENT_ANALYSIS` (back-transition, requirement malformed/contradictory) | + +## Purpose + +Gather external and internal evidence (domain, competitors, patterns, +constraints) that informs product and UX decisions. Downstream states should be +able to reason from cited findings rather than re-searching the problem space. + +## Processing steps + +1. Derive **research questions** from `requirements.md`. +2. Fan out searches by **modality**: domain, competitor, interaction pattern, + technical constraint. +3. Collect **evidence with citations**; dedupe. +4. Synthesize findings into **themes**; note **contradictions**. +5. Rank findings by **relevance to acceptance criteria**. +6. Emit **unresolved-evidence gaps**. + +## Output — `artifacts/research.md` + +Write with structured frontmatter + body so downstream skills and the machine +can validate mechanically. + +```markdown +--- +artifact: research +version: <hash-or-incrementing-id> +produced_by: research +reads_version: <requirements.md version consumed> +coverage: <mapped-or-waived % of goals> +--- + +## Themes +- T1: <theme statement> + - sources: [S1, S3] + - relevance: <which acceptance criteria / goals this informs> + - maps_to: [G1, G2] # goal IDs from requirements.md +- T2: ... + +## Evidence & citations +- S1 [resolvable]: <claim/finding> — <source: url or reference> +- S2 [resolvable]: <claim/finding> — <source> + +## Competitor notes +- <competitor>: <observation> (sources: [S2]) + +## Pattern catalog +- P1: <interaction/design pattern> — <where observed> (sources: [S4]) + +## Constraints +- <technical / domain / regulatory constraint> (sources: [S5]) + +## Contradictions +- C1: <finding A> vs <finding B> — <both sources cited, left unresolved> + +## Goal coverage +- G1 → [T1] | G2 → [T2] | G3 → no-research-needed + +## Gaps +- GAP1: <unresolved evidence gap or downgraded theme> [reason] +``` + +## Validation rules (machine-checkable on output) + +- **V1:** Every theme cites ≥1 source. +- **V2:** Each requirement goal maps to ≥1 research theme **OR** is explicitly + marked `no-research-needed`. +- **V3:** Contradictions are listed, not silently resolved. +- **V4:** No fabricated citations — every source is resolvable. + +## Exit conditions + +All validation rules pass **AND** goal coverage ≥ configured threshold +(default 100% mapped-or-waived). + +## Failure recovery + +- On coverage/citation failure: re-run the fan-out targeting **only the failed + goals**. Increment `entry_count[RESEARCH]` / `loop_count[L_RESEARCH]`. +- Retry ceiling 2 (`L_RESEARCH`). On repeated fabrication-risk failure → + downgrade unreachable themes to `gap` and continue with a logged warning. +- If research reveals a requirement is malformed or contradictory → + back-transition to `REQUIREMENT_ANALYSIS` (root cause is upstream). + +## Approval gate + +None. Research produces evidence for later gates but raises no approval of its +own. diff --git a/design-toolkit/skills/03-product-review/README.md b/design-toolkit/skills/03-product-review/README.md new file mode 100644 index 0000000..a4e34ed --- /dev/null +++ b/design-toolkit/skills/03-product-review/README.md @@ -0,0 +1,27 @@ +# Skill: `product-review` — STATE 03 + +**Decide whether requirements + evidence justify spending design effort.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 03 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-03--product_review) + +[← skills/](../README.md) · prev ← [02 research](../02-research/README.md) · next → [04 ux-planning](../04-ux-planning/README.md) + +| Field | Value | +|---|---| +| Machine state | `PRODUCT_REVIEW` | +| Reads | `requirements-<feature>.md`, `research-<feature>.md` | +| Writes | `product-review-<feature>.md` | +| Depends on | research | +| Approval gate | **Direction Approval Gate** — mandatory, always fires | +| Retry ceiling | 2 | +| Next states | `UX_PLANNING` / `REQUIREMENT_ANALYSIS` / `HALT_STOPPED` | + +## The rule that matters most here + +This state **judges** scope and never **adds** it. The last cheap place to stop or re-cut — everything downstream compounds on the direction ratified here. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/03-product-review/SKILL.md b/design-toolkit/skills/03-product-review/SKILL.md new file mode 100644 index 0000000..0136a3a --- /dev/null +++ b/design-toolkit/skills/03-product-review/SKILL.md @@ -0,0 +1,151 @@ +--- +name: product-review +description: >- + State 03 of the AI Product Design Agent workflow. Decides whether the + requirements and the research evidence justify spending design effort, and + emits a proceed / re-scope / stop recommendation before any UX work begins. + Use when a validated requirements artifact and a research artifact both exist + and the direction has not yet been approved: requirements are reconciled + against evidence, scored on value/effort/risk, contradictions between desired + scope and evidence are named rather than absorbed, high-risk items get a + mitigation or an explicit accept-risk note, and the decision is recorded with + its triggers. Reads requirements.md and research.md; writes product-review.md. + Depends on research. Raises the mandatory Direction Approval Gate. +--- + +# Product Review (STATE 03) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 03. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests a product review. It +> communicates only through the artifact store (`artifacts/`), never directly +> with other skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | `artifacts/requirements.md`, `artifacts/research.md`, `machine_state` | +| Writes | `artifacts/product-review.md` | +| Depends on | `research` (must precede) | +| Approval gate | **Direction Approval Gate** — mandatory, always fires | +| Retry ceiling | 2 (`entry_count[PRODUCT_REVIEW]`), re-run scoring with the failed rule as constraint | +| Next states | `UX_PLANNING` (`proceed` + gate approved) / `REQUIREMENT_ANALYSIS` (`re-scope`, or gate denied) / `HALT_STOPPED` (`stop` + gate confirms) | + +## Purpose + +Decide whether the requirements plus the research evidence justify the product +direction, **before** design resources are spent. This is the last cheap place +to stop or re-cut scope: everything downstream (UX plan, flows, UI plan, +prototype) compounds on the direction ratified here. + +This state **judges** scope. It never **adds** scope — every prioritized item +must already exist in `requirements.md`. + +## Processing steps + +1. **Reconcile** each requirement against the research evidence: which themes + support it, which contradict it, which leave it unevidenced. +2. **Score** every requirement on **value / effort / risk** and derive a + priority band (`must` / `should` / `could` / `cut`). +3. **Identify contradictions** between desired scope and evidence — where the + brief wants something the research says is wrong, unproven, or unowned. +4. **Build the risk register**: every high-risk item gets a mitigation **or** an + explicit accept-risk note naming who accepts it. +5. Produce a **`proceed` / `re-scope` / `stop` recommendation** with written + rationale tied to the scores and contradictions above. +6. **Record the decision and its triggers** — what evidence would reverse it. +7. Raise the **Direction Approval Gate**. + +## Output — `artifacts/product-review.md` + +Write with structured frontmatter + body so downstream skills and the machine +can validate mechanically. + +```markdown +--- +artifact: product-review +version: <hash-or-incrementing-id> +produced_by: product-review +reads_version: + requirements: <requirements.md version consumed> + research: <research.md version consumed> +recommendation: proceed | re-scope | stop +gate: direction-approval +gate_state: pending | approved | denied +--- + +## Recommendation + +**<proceed | re-scope | stop>** — <rationale, tied to the scores and +contradictions below. Name the two or three findings that actually drove it.> + +## Prioritized requirements + +| ID | Requirement | Value | Effort | Risk | Band | Evidence | +|---|---|---|---|---|---|---| +| R1 | <text, verbatim from requirements.md> | H/M/L | H/M/L | H/M/L | must | [T1, T4] | +| R2 | ... | | | | should | [T2] | +| R7 | ... | | | | cut | unevidenced | + +## Risk register + +| ID | Risk | Sev | Mitigation **or** accept-risk | Owner | +|---|---|---|---|---| +| K1 | <risk statement> | high | <mitigation> | <role> | +| K2 | <risk statement> | high | **ACCEPTED** — <why, and by whom> | <role> | + +## Scope contradictions + +- X1: <brief wants A> vs <research theme T3 shows B> — <resolution, or + explicitly left open with an open-decision id> + +## Decision record + +- D1: <decision> — trigger: <what evidence or event would reverse it> +- Deferred: <open decisions handed to later states, with ids> + +## Cut list + +- <requirement id> — <why it is out for this cycle, and what would bring it back> +``` + +## Validation rules (machine-checkable on output) + +- **V1:** `recommendation` ∈ {`proceed`, `re-scope`, `stop`} **and** the + rationale block is non-empty. +- **V2:** Every risk with `Sev = high` carries a mitigation **or** an explicit + accept-risk note with a named owner. +- **V3:** The prioritized set is a **subset** of the validated requirements — + every ID resolves in `requirements.md`. No new scope is introduced here. +- **V4:** Every prioritized requirement cites its evidence (research theme IDs) + **or** is explicitly marked `unevidenced`. + +## Exit conditions + +All validation rules pass **AND** the **Direction Approval Gate** is resolved. + +## Failure recovery + +- On validation failure: re-run **scoring only**, with the failed rule as an + explicit constraint. Increment `entry_count[PRODUCT_REVIEW]`. +- Retry ceiling **2**. Escalate rather than widen scope to satisfy a rule — a + V3 failure means an upstream requirement is missing, not that this state + should invent one. +- If the user **denies** direction at the gate → back-transition to + `REQUIREMENT_ANALYSIS` carrying the denial notes as input. +- If the recommendation is `re-scope` → `REQUIREMENT_ANALYSIS` with the cut + list and contradictions attached. +- If the recommendation is `stop` and the gate confirms → `HALT_STOPPED`. + +## Approval gate + +**Direction Approval Gate** — mandatory, fires every time. It exists to prevent +spending design effort on an unapproved direction. Gate state persists in +`machine_state.approvals`; an approval is scoped to the artifact version it +saw. A later revision of `product-review.md` re-opens the gate. + +Present at the gate: the recommendation and its rationale, the priority bands, +the high-risk items with their mitigation-or-acceptance, and the cut list. +Unresolved contradictions are presented **as unresolved** — a gate answered on +a tidied-up picture is not an approval of the real direction. diff --git a/design-toolkit/skills/04-ux-planning/README.md b/design-toolkit/skills/04-ux-planning/README.md new file mode 100644 index 0000000..7ccd748 --- /dev/null +++ b/design-toolkit/skills/04-ux-planning/README.md @@ -0,0 +1,27 @@ +# Skill: `ux-planning` — STATE 04 + +**Define the UX strategy — tasks, IA, state enumeration, accessibility posture.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 04 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-04--ux_planning) + +[← skills/](../README.md) · prev ← [03 product-review](../03-product-review/README.md) · next → [05 flow-generation](../05-flow-generation/README.md) + +| Field | Value | +|---|---| +| Machine state | `UX_PLANNING` | +| Reads | `requirements`, `research`, `product-review` | +| Writes | `ux-plan-<feature>.md` | +| Depends on | product-review | +| Approval gate | None — non-blocking checkpoint review | +| Retry ceiling | 2 (`L_UX_EDGE`) | +| Next states | `FLOW_GENERATION` / `PRODUCT_REVIEW` / self-loop | + +## The rule that matters most here + +Deliberately **screen-free**. The accessibility floor stated here becomes an acceptance criterion the audit checks — set the number you will actually build. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/04-ux-planning/SKILL.md b/design-toolkit/skills/04-ux-planning/SKILL.md new file mode 100644 index 0000000..3c2ba4d --- /dev/null +++ b/design-toolkit/skills/04-ux-planning/SKILL.md @@ -0,0 +1,166 @@ +--- +name: ux-planning +description: >- + State 04 of the AI Product Design Agent workflow. Defines the UX strategy — + primary tasks, information architecture, per-task state enumeration including + every non-happy path, and the accessibility posture — without producing a + single screen. Use when a direction has been approved and design needs a + strategy before flows exist: tasks are derived from the prioritized + requirements, the IA and navigation model are set, each task enumerates its + happy path plus at least three non-happy-path states (error, empty, loading, + interrupted, offline, permission-denied), accessibility and reduced-motion are + planned at the strategy level, and UX risks and open decisions are named + rather than designed around. Reads requirements.md, research.md and + product-review.md; writes ux-plan.md. Depends on product-review. No approval + gate — non-blocking checkpoint review only. +--- + +# UX Planning (STATE 04) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 04. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests UX planning. It communicates +> only through the artifact store (`artifacts/`), never directly with other +> skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | `artifacts/requirements.md`, `artifacts/research.md`, `artifacts/product-review.md`, `machine_state` | +| Writes | `artifacts/ux-plan.md` | +| Depends on | `product-review` (must precede; its Direction Approval Gate must be approved) | +| Approval gate | None — checkpoint review only, non-blocking | +| Retry ceiling | 2 (`L_UX_EDGE`, `UX_PLANNING` self-loop), then flag `partial-coverage` | +| Next states | `FLOW_GENERATION` (normal) / `PRODUCT_REVIEW` (back-transition, priorities unviable) / `UX_PLANNING` (self-loop, edge-case coverage gap) | + +## Purpose + +Define the UX **strategy** — information architecture, key tasks, states, and +non-happy-path coverage — so that flow generation has something to sequence and +UI planning has something to lay out. + +This state is deliberately **screen-free**. The moment it names a screen or a +visual treatment it has skipped the decision it exists to make, and V4 fails. + +## Processing steps + +1. Derive **primary user tasks** from the **prioritized** requirements — the + `must` / `should` bands of `product-review.md`, not the raw requirement set. +2. Model the **information architecture** and the **navigation model**. +3. Enumerate **states per task**: the happy path **and** the non-happy paths — + error, empty, loading, interrupted, offline, permission-denied. +4. Define **accessibility and reduced-motion requirements** at the strategy + level (targets, contrast posture, focus order, motion opt-out, script/locale + handling). +5. Note **UX risks and open decisions**, each with an id that later states and + the review gate can cite. + +## Output — `artifacts/ux-plan.md` + +Write with structured frontmatter + body so downstream skills and the machine +can validate mechanically. + +```markdown +--- +artifact: ux-plan +version: <ux-<feature>-NN> +produced_by: ux-planning +reads_versions: + requirements.md: <version> + research.md: <version> + product-review.md: <version> +feature: <feature/flow id> +coverage: <tasks with full state enumeration> / <total tasks> +--- + +# UX Plan — <feature> + +## Primary tasks (→ requirements) + +| Task | User intent | Reqs | +|---|---|---| +| TASK-A | <what the user is trying to do> | R1, R4 | +| TASK-B | ... | R2 | + +## Information architecture + +<the content/entity model and where each task lives inside it> + +## Navigation model + +<how a user moves between the IA's regions: entry points, persistent +navigation, push vs replace, back semantics, deep-link posture> + +## State enumeration + +### TASK-A +| State | Kind | Trigger | Strategy | +|---|---|---|---| +| happy | happy | <trigger> | <what the user experiences> | +| loading | non-happy | <trigger> | ... | +| empty | non-happy | <trigger> | ... | +| error | non-happy | <trigger> | ... | +| interrupted | non-happy | <trigger> | ... | +| permission-denied | non-happy | <trigger> | ... | + +### TASK-B +... + +## Edge-case matrix + +| | loading | empty | error | interrupted | offline | permission-denied | +|---|---|---|---|---|---|---| +| TASK-A | ✅ | ✅ | ✅ | ✅ | n/a — <why> | ✅ | +| TASK-B | ✅ | ✅ | ✅ | ✅ | ✅ | n/a — <why> | + +## Accessibility strategy + +- Interactive target floor: <value> — **state the number here**; it becomes an + acceptance criterion the audit checks against, so an unrealistic one becomes + debt later. +- Contrast posture, focus order, keyboard reachability, screen-reader + expectations, motion opt-out, script/locale and per-glyph font handling. + +## UX risks + +- U1: <risk> — <what it threatens> — <planned response> + +## Open decisions + +- o-<id>: <question> — <what it blocks> — <who can rule> +``` + +## Validation rules (machine-checkable on output) + +- **V1:** Every primary task enumerates a happy path **AND ≥3 + non-happy-path** states. +- **V2:** The accessibility strategy is present and non-empty. +- **V3:** Every task traces to ≥1 **prioritized** requirement. +- **V4:** **No screen-level or visual design content** — strategy only. No + screen ids, no layout, no component names, no colour. + +## Exit conditions + +All validation rules pass. + +## Failure recovery + +- On **V1** failure: re-run the enumeration targeting **only** the tasks with + missing coverage. Increment `loop_count[L_UX_EDGE]`. +- On **V4** failure: strip the screen-level content back out. Design detail that + arrives here is not free — it pre-commits `UI_PLANNING` to a layout nobody + chose. +- Retry ceiling **2**, then flag `partial-coverage` and continue **only if** + `product-review.md`'s risk tolerance allows it. Otherwise escalate. +- If UX planning reveals the prioritized set is **unviable** → back-transition + to `PRODUCT_REVIEW`. Do not quietly re-prioritize here; that scope decision + belongs to a gated state. + +## Approval gate + +None. Checkpoint review only, non-blocking — the direction was already approved +at STATE 03, and the prototype has its own gate at STATE 09. **Open decisions +recorded here are carried forward**, not resolved by assumption: an unruled +question that reaches the prototype as an invented answer is how a placeholder +ends up frozen into an approved deliverable. diff --git a/design-toolkit/skills/05-flow-generation/README.md b/design-toolkit/skills/05-flow-generation/README.md new file mode 100644 index 0000000..9b9ba42 --- /dev/null +++ b/design-toolkit/skills/05-flow-generation/README.md @@ -0,0 +1,27 @@ +# Skill: `flow-generation` — STATE 05 + +**Turn tasks and states into directed flow graphs with exhaustive branches.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 05 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-05--flow_generation) + +[← skills/](../README.md) · prev ← [04 ux-planning](../04-ux-planning/README.md) · next → [06 ui-planning](../06-ui-planning/README.md) + +| Field | Value | +|---|---| +| Machine state | `FLOW_GENERATION` | +| Reads | `ux-plan-<feature>.md`, `requirements` | +| Writes | `flows-<feature>.md` | +| Depends on | ux-planning | +| Approval gate | None | +| Retry ceiling | 3 | +| Next states | `UI_PLANNING` / `UX_PLANNING` / self-loop | + +## The rule that matters most here + +"Exhaustive" means the branch set covers the guard's whole domain — including null, not-yet-loaded and permission-denied. An unanswered guard is an open decision, never an invented default. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/05-flow-generation/SKILL.md b/design-toolkit/skills/05-flow-generation/SKILL.md new file mode 100644 index 0000000..0b5d98b --- /dev/null +++ b/design-toolkit/skills/05-flow-generation/SKILL.md @@ -0,0 +1,179 @@ +--- +name: flow-generation +description: >- + State 05 of the AI Product Design Agent workflow. Turns the UX plan's tasks and + states into directed flow graphs — sequenced transitions, decision points with + exhaustive branch conditions, a recovery route for every non-happy-path state, + and a reachability report proving no unreachable state and no unjustified dead + end. Use when a UX strategy exists and design needs concrete flows before any + screen or component is named: each task becomes a graph, every edge carries a + trigger and a guard, every branch is mutually exhaustive, and every flow + boundary into another feature is marked rather than assumed. Reads ux-plan.md + and requirements.md; writes flows.md. Depends on ux-planning. No approval gate. +--- + +# Flow Generation (STATE 05) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 05. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests flow generation. It +> communicates only through the artifact store (`artifacts/`), never directly +> with other skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | `artifacts/ux-plan.md`, `artifacts/requirements.md`, `machine_state` | +| Writes | `artifacts/flows.md` (per feature: `flows-<feature>.md`) | +| Depends on | `ux-planning` (must precede) | +| Approval gate | None | +| Retry ceiling | 3 (unnamed `FLOW_GENERATION` self-loop — flow-heavy state, §6), then back-transition to `UX_PLANNING` | +| Next states | `UI_PLANNING` (normal) / `UX_PLANNING` (back-transition, flow modeling exposes a missing state) / `FLOW_GENERATION` (self-loop, dead-end or reachability fix) | + +## Purpose + +Produce concrete user flows and state transitions that connect the UX plan's +tasks and states, so `UI_PLANNING` has a graph to lay out instead of a list to +interpret. + +This state is **screen-free in the same sense STATE 04 is**: a node is a *state* +the user is in, not a visual design. Naming a node after a screen id is fine — +and is the toolkit's convention — but the node's contents are still triggers, +guards and routes, never layout, component names or colour. + +## Processing steps + +1. For each task in `ux-plan.md`, **sequence its states into a directed flow**. +2. Insert **decision points** and their **branch conditions**. +3. Map **every non-happy-path state to a recovery route** — the state must lead + somewhere the user can act, not just be reachable. +4. **Detect and eliminate dead ends and unreachable states.** Produce the + reachability report as evidence, not as a claim. +5. **Annotate every transition with its trigger and guard.** + +## Output — `artifacts/flows.md` + +Structured frontmatter + body, so downstream skills and the machine can validate +mechanically. Per-feature file naming (`flows-checkout.md`, `flows-onboarding.md`) is the +toolkit convention; the contract is identical. + +```markdown +--- +artifact: flows +version: flow-<feature>-NN +supersedes: <prior version, if any> +produced_by: flow-generation +reads_versions: { ux-plan-<feature>.md: <version>, requirements-<feature>.md: <version> } +feature: <feature/flow id> +folds_in: [<revision rounds / decisions this version absorbs>] +--- + +# Flows — <feature> + +Node ids = prototype views. `⟂` = flow boundary (another flow's screen, mocked). + +## Canon entry paths + +<the named paths a user actually takes in, one line each — these are what a +reviewer checks the prototype against> + +## F1 — <segment name> (<tasks covered>) + +``` +⟂OTHER-01 ──trigger──▶ [D1 <question>?] + D1 no ──▶ NODE-A ──trigger──▶ NODE-B + D1 yes ──▶ NODE-B directly +NODE-B ──[D2 <question>?] + D2 <case> ──▶ NODE-C + D2 <case> ──▶ inline recovery (self-loop; recovery: <what the user can do>) +``` + +**D1:** <the guard, as a checkable expression> · **D2:** <guard> — all exhaustive. + +<prose only where the diagram cannot carry it: contested tap targets, suppression +rules, decisions that changed a branch> + +## F2 — ... + +## Decision log + +| ID | Decision | Ruled by | Date | +|---|---|---|---| +| D-<x>N | <what was decided, and what it replaced> | user / audit / this state | <date> | + +## Reachability report + +| Node | Reachable from | Terminal? | Justification if terminal | +|---|---|---|---| +| NODE-A | entry, NODE-C | no | — | +| NODE-Z | NODE-C | **yes** | <why terminating here is correct> | + +Unreachable nodes: **0**. Dead ends without justification: **0**. + +## Recovery coverage + +| Non-happy state (ux-plan) | Recovery transition | +|---|---| +| TASK-A / error | → retry edge on NODE-B | +| TASK-A / empty | → NODE-A with a route-out CTA | + +## Flow boundaries + +| Boundary node | Owning flow | Status | +|---|---|---| +| ⟂OTHER-01 | <flow> | mocked / real handoff | + +## Open decisions + +- o-<id>: <question> — <which branch it leaves unruled> — <who can rule> +``` + +## Validation rules (machine-checkable on output) + +- **V1:** No unreachable state in any flow. +- **V2:** No dead-end state without an explicit terminal justification. +- **V3:** Every non-happy-path state from `ux-plan.md` has a recovery + transition. +- **V4:** Every decision point has **mutually exhaustive** branch conditions. + +V4 is the one that fails quietly. "Exhaustive" means the branch set covers the +guard's whole domain — including null, not-yet-loaded and permission-denied — +not merely that two plausible cases are listed. + +## Exit conditions + +All validation rules pass **and** the reachability report is clean. + +## Failure recovery + +- On **V1 / V2** failure: patch the **offending flow segment** and re-validate + **only that segment**. Do not regenerate the whole graph — a full rewrite + loses the ratified decisions the diagram encodes. +- On **V3** failure: the missing recovery is usually a missing *state*, not a + missing edge → check whether the fault is upstream before adding an edge that + invents one. +- On **V4** failure: add the missing branch. If the missing branch has no ruled + answer, it is an **open decision**, not a branch to invent. +- Retry ceiling **3**. A **persistent unreachable state** means `ux-plan.md` + omitted a state → back-transition to `UX_PLANNING`. Do not add the state here; + that enumeration belongs to STATE 04. + +## Approval gate + +None. Flows are ratified indirectly, at the STATE 09 gate, through the prototype +that implements them. + +## Recorded failure modes + +Defect classes that shipped past a clean flow graph on the run this toolkit was +extracted from. Each is a rule, not an anecdote — codes in +[`docs/method-rules.md`](../../docs/method-rules.md). + +| Class | What happened | Rule | +|---|---|---| +| **Stale boundary** | Eleven live navigation call sites still routed to the placeholder boundary screen after every destination flow had shipped. A `⟂` node is correct *when written* and silently wrong once the owning flow exists. | The **Flow boundaries** table is re-checked whenever any other feature reaches `FINAL_OUTPUT` — a boundary's `status` is a dated claim, not a permanent property. | +| **Scoped claim read as global** | "No boundary mocks left" was written about one flow's mocks and read as holding for the set. | State the **scope of a clearance claim in the claim itself**. | +| **Cross-flow canon drift** | Two approved flows shipped contradictory values for the same user-visible fact. Each flow graph was internally consistent. | A fact promised at a `⟂` boundary is a **cross-flow contract**. Record it in the decision log of both flows, or it drifts. | +| **Branch invented, not ruled** | Unruled product numbers and unowned scope questions entered flows as concrete branches and were frozen into approved deliverables. | An unanswered guard is an **open decision carried forward** (`o-<id>`), never a default silently chosen here. | +| **Graph green, screen blank** | A flow whose every node and edge was correct rendered nothing — the view container stayed `visibility:hidden` because nothing ever activated it. | Flow correctness is **not** implementation correctness. This state's verdict is scoped to the graph; the render is [`skills/08`](../08-self-audit/SKILL.md)'s job (M1/M2). | diff --git a/design-toolkit/skills/06-ui-planning/README.md b/design-toolkit/skills/06-ui-planning/README.md new file mode 100644 index 0000000..034d2e0 --- /dev/null +++ b/design-toolkit/skills/06-ui-planning/README.md @@ -0,0 +1,27 @@ +# Skill: `ui-planning` — STATE 06 + +**Component inventory, DS mapping, layout, motion and contrast — by reference.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 06 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-06--ui_planning) + +[← skills/](../README.md) · prev ← [05 flow-generation](../05-flow-generation/README.md) · next → [07 prototype](../07-prototype/README.md) + +| Field | Value | +|---|---| +| Machine state | `UI_PLANNING` | +| Reads | `flows`, `ux-plan`, `research`, the design system | +| Writes | `ui-plan-<feature>.md` | +| Depends on | flow-generation | +| Approval gate | None — may raise an informational **Extension Note** | +| Retry ceiling | 2 | +| Next states | `PROTOTYPE` / `FLOW_GENERATION` / self-loop | + +## The rule that matters most here + +Name the design system **by source id**. A plan built on the wrong system validates perfectly against it, and no downstream rule can catch it. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/06-ui-planning/SKILL.md b/design-toolkit/skills/06-ui-planning/SKILL.md new file mode 100644 index 0000000..ce6fcc0 --- /dev/null +++ b/design-toolkit/skills/06-ui-planning/SKILL.md @@ -0,0 +1,226 @@ +--- +name: ui-planning +description: >- + State 06 of the AI Product Design Agent workflow. Turns flow graphs into a UI + specification — a component inventory mapped to the design system reuse-first, + layout and hierarchy rules per state, motion and contrast decided on the token + pair, and every value referenced to a token rather than invented. Use when + flows exist and the prototype needs a spec instead of an improvisation: each + flow state gets a component set, every component maps to a DS primitive or + carries a written justification for being new, no one-off styling is introduced + where a primitive exists, and a token that does not resolve becomes an + extension request or an open decision — never a new hex. Reads flows.md, + ux-plan.md and research.md plus the design-system reference; writes ui-plan.md. + Depends on flow-generation. No approval gate — may raise an informational + Extension Note. +--- + +# UI Planning (STATE 06) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 06. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests UI planning. It communicates +> only through the artifact store (`artifacts/`), never directly with other +> skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | `artifacts/flows.md`, `artifacts/ux-plan.md`, `artifacts/research.md`, `machine_state`, design-system reference | +| Writes | `artifacts/ui-plan.md` (per feature: `ui-plan-<feature>.md`) | +| Depends on | `flow-generation` (must precede) | +| Approval gate | None — may raise an informational, non-blocking **Extension Note** | +| Retry ceiling | 2 (unnamed `UI_PLANNING` self-loop, §6), then unavoidable new components route through an Extension Note | +| Next states | `PROTOTYPE` (normal) / `FLOW_GENERATION` (back-transition, UI planning reveals a flow gap) / `UI_PLANNING` (self-loop, reuse or mapping fix) | + +## Purpose + +Define UI structure, component inventory and design-system usage — **still +specification, not rendered UI** — so `PROTOTYPE` assembles a plan instead of +improvising one. + +The boundary that defines this state: it names components, tokens, geometry and +motion **by reference**, and builds nothing. Everything it writes must be +checkable against the design system before a line of the prototype exists. A +decision deferred to build time is a decision made by whoever builds fastest. + +## Processing steps + +1. **Decompose each flow state** from `flows.md` into its required UI regions + and components. +2. **Map components to existing DS primitives first**; flag the gaps that need + an extension. Reuse is the default, `new` is the exception that argues for + itself. +3. **Define layout and hierarchy rules per state** — at the real viewport, with + real numbers. +4. **Specify tokens** (spacing, colour, typography, motion) **by reference**. + Values are not invented here. +5. **Produce the component inventory** with a reuse-vs-new classification, and + a written justification against every `new`. + +## Output — `artifacts/ui-plan.md` + +Structured frontmatter + body so downstream skills and the machine can validate +mechanically. Per-feature file naming (`ui-plan-checkout.md`, `ui-plan-onboarding.md`) +is the toolkit convention; the contract is identical. + +```markdown +--- +artifact: ui-plan +version: ui-<feature>-NN +produced_by: ui-planning +reads_versions: + flows-<feature>.md: <version> + ux-plan-<feature>.md: <version> + research-<feature>.md: <version> + design-system: "<DS name + the source id it came from>" +feature: <feature/flow id> +inherits: <prior ui-plan / shell this one builds on, and what it takes verbatim> +supersedes: <prior version, if any> +--- + +# UI Plan — <feature> + +## Inherited, reused verbatim + +<the token layer, chrome primitives, i18n pattern, asset resolvers, effects and +guards taken unchanged from the shell — named explicitly, because anything not +listed here is something this plan is claiming to introduce> + +## STRICT colour allowlist (V5) + +``` +<the exact hex set, grouped: brand / gray / semantic / exempt classes> +BANNED: <hexes that must not appear, and where they came from> +``` + +Write the same set into `toolkit.config.json` → `audit.colorAllowlist` / +`colorBanned`. That is where STATE 08's harness reads it; an allowlist that lives +only in this document is an allowlist nothing enforces. + +The **BANNED** list is not optional. `SELF_AUDIT` enforces this allowlist by +machine hex-extraction, and the permanent lesson attached to that rule is that +an audit must check **non-DS absence, not just DS presence** — an allowlist +alone cannot catch a value that was never supposed to exist. + +Naming note: this "V5" is the **`SELF_AUDIT` hex-conformance rule id**. It is not +a validation rule of this state — this state's rules are +V1–V4 — and it is a different V5 from the rendering-class rule defined in +[`skills/08`](../08-self-audit/SKILL.md). + +## Component inventory → DS mapping (reuse-first) + +| Comp | Surface | DS mapping | new? | +|---|---|---|---| +| <component> | <flow state / screen> | `<primitive>` | reuse | +| <component> | <state> | `<primitive>` + <variant> | reuse+variant | +| <component> | <state> | <what it is assembled from> | **new**, justified: <why no primitive covers it, and what it is built out of> | + +<count line: N of M entries are reuse or variants; every `new` carries a +justification — this is V2's evidence> + +## Layout rules (<viewport>) + +<per state: the vertical budget in px, the grid of each repeated row, the +minimum tap target of every interactive element, scroll vs pinned, sheet +geometry, safe-area and small-viewport behaviour> + +## Motion spec + +| Element | Default | Reduced | +|---|---|---| +| <element> | <duration + curve, by token> | <static equivalent> | + +<every row forked for `.reduce` and `prefers-reduced-motion` — the ux-plan's +motion opt-out is an acceptance criterion, not a nicety> + +## Contrast + +<the audited pairs, with ratios. Decided here on the token pair, not at audit +time. A pairing that fails is a plan change.> + +## Token reference resolution (V4) + +<every value resolves to a token or a documented exemption. State the count of +new hex introduced — the target is zero.> + +## Superseded + +| Component | Replaced by | Strip in prototype | +|---|---|---| +| <old component + its selectors> | <new one> | yes | + +## Extension Note (informational, non-blocking) + +<the `new` compositions, why the DS has no primitive, what they are built from, +and whether they are recommended for promotion into the DS> + +## Open decisions + +- o-<id>: <question> — <what it leaves unresolved> — <who can rule> + +## Validation self-check + +- **V1** ✅/❌ <every flow state maps to a component set — list the states> +- **V2** ✅/❌ <reuse ratio + every `new` justified> +- **V3** ✅/❌ <no one-off styling where a primitive exists> +- **V4** ✅/❌ <token references resolve; new hex count> + +**Exit:** validation passes → `PROTOTYPE` (<proto id>). +``` + +## Validation rules (machine-checkable on output) + +- **V1:** Every flow state maps to a component set. +- **V2:** Component-to-DS mapping **prefers reuse**; every `new` component has a + justification. +- **V3:** No isolated one-off styling introduced where a DS primitive exists. +- **V4:** Token references **resolve** to the provided design system, or are + flagged as an extension. + +V4 is the one that fails quietly. "Resolves" means the token exists in the named +design system — not that a plausible-looking `var()` name was written down. A +token the DS does not have is an extension request or an open decision; it is +never a hex quietly added to the allowlist. + +## Exit conditions + +All validation rules pass. + +## Failure recovery + +- On **V2 / V3** failure: re-map the offending components **toward DS reuse**. + Fix the mapping, not the justification — a better-argued one-off is still a + one-off. +- On **V1** failure: check whether the unmapped state is a *missing state* + rather than a missing component set. If `flows.md` is short a state → + back-transition to `FLOW_GENERATION`. Do not invent the state here. +- On **V4** failure: raise an **extension request**. If the missing token + encodes an unruled product decision (a colour nobody owns, a size nobody set), + it is an **open decision** carried forward, not a value chosen here. +- Retry ceiling **2**. Unavoidable new components route through an **Extension + Note** appended to `ui-plan.md` — informational, non-blocking. The Note is the + escalation edge, not a back-transition. + +## Approval gate + +None. The Extension Note is informational: it exists so a DS owner can later see +what the product needed and the system did not have. It does not block the +transition to `PROTOTYPE`. + +## Recorded failure modes + +Defect classes that shipped past a plausible-looking UI plan on the run this +toolkit was extracted from. Each is a rule, not an anecdote. + +| Class | What happened | Rule | +|---|---|---| +| **Wrong design system** | A spec belonging to a **different project** was adopted and survived four revision cycles; the machine reached **`HALT_BLOCKED` at the revision ceiling** before the tell was spotted (a desktop-first viewport assumption inside a mobile product). A plan built on the wrong system validates **perfectly** against it, so no downstream rule can catch it. | Name the DS **by source id** in `reads_versions.design-system` and in `toolkit.config.json` → `designSystem.sourceId`, then sanity-check that the system's own assumptions (viewport, platform, brand) match this product's. A DS reference without an id is unverifiable — and every rule in this state is only as true as that one line. | +| **Token that does not exist** | A spec asked for two hues the palette does not contain. The nearest allowlisted ramps shipped and an open was raised — correctly. The failure mode is the alternative: adding two hexes to the allowlist to make the spec true. | A token that does not resolve is an **extension request or an open**, never a new hex. The counter-example in the same run: a five-rung tier ladder that resolved inside the existing allowlist with **zero additions**. | +| **Contrast decided after the fact** | A progress bar failed **3:1 against its own track**, so it shipped as the theme accent — contradicting the same spec's one-colour-per-category rule. The later flow avoided the repeat by rejecting a gradient at plan time, because neither ink nor white cleared 4.5:1 at its midpoint. | Contrast is decided **here, on the token pair**, with the ratio written down. A pairing that fails is a **plan** change, not a prototype patch. | +| **Selector namespace not claimed** | A descendant selector on a new card also matched status and lock icons elsewhere and inflated them to ~340px; an inline-span badge overflowed its card and clipped the lines below it. Both were **new compositions dropped into an inherited shell**. | A `new` entry declares the **selector namespace it claims** and scopes its descendants (`> svg`). The inventory is where a collision is cheap to see. | +| **Inventory only added** | One revision superseded an entire component outright; two rebuilds later required stripping whole selector families and their string keys by hand. | When a revision replaces a component, record the **supersession** so the prototype strips it. An inventory that only grows accumulates dead style that no audit reads. | +| **Token layer as opt-in** | The script→font token was **opt-in per component** over a foreign base, so any element that did not opt in rendered on an arbitrary OS fallback — **138 instances across 7 flows** — and the base stack turned out to carry no face for that script at all. | Script → font mapping is a **base rule of the token layer**, not a per-component choice. Anything a component can forget to do, some component will forget to do. | +| **Shared component, no owner** | The bottom navigation drifted into three variants across files; a re-cut then relabelled a slot while every file kept the old glyph, so the tab read one thing under the icon for another. | A component used by more than one flow is a **cross-flow contract**. The inventory names its owning plan, or each file re-decides it. | +| **Geometry that contradicts the strategy** | Layout rules set four interactive elements between 29 and 38px against a `ux-plan` floor of **44px**. ~140 elements on 20 screens passed the external AA standard but missed the project's own acceptance criterion, and raising them would have restyled four approved gates. | The `ux-plan`'s accessibility floor is an **acceptance criterion**, and it is `toolkit.config.json` → `audit.tapTargetFloorPx`. State target sizes as numbers here and check them against it — this is the last state where the number is free to change. | diff --git a/design-toolkit/skills/07-prototype/README.md b/design-toolkit/skills/07-prototype/README.md new file mode 100644 index 0000000..9d56e55 --- /dev/null +++ b/design-toolkit/skills/07-prototype/README.md @@ -0,0 +1,27 @@ +# Skill: `prototype` — STATE 07 + +**Assemble the specs into a coherent prototype plus its traceability map.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 07 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-07--prototype) + +[← skills/](../README.md) · prev ← [06 ui-planning](../06-ui-planning/README.md) · next → [08 self-audit](../08-self-audit/README.md) + +| Field | Value | +|---|---| +| Machine state | `PROTOTYPE` | +| Reads | `ui-plan`, `flows`, `ux-plan` | +| Writes | `prototype/`, `traceability-<feature>.md` | +| Depends on | ui-planning | +| Approval gate | None | +| Retry ceiling | 3 | +| Next states | `SELF_AUDIT` / `UI_PLANNING` / self-loop | + +## The rule that matters most here + +Every flow state ships a **deep-link hook** (B2). A state that cannot be driven cannot be audited by 08 or demonstrated at the 09 gate — and the hook table is the review packet. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/07-prototype/SKILL.md b/design-toolkit/skills/07-prototype/SKILL.md new file mode 100644 index 0000000..eb28ee2 --- /dev/null +++ b/design-toolkit/skills/07-prototype/SKILL.md @@ -0,0 +1,350 @@ +--- +name: prototype +description: >- + State 07 of the AI Product Design Agent workflow. Assembles the planned UI and + flows into a coherent, interactive prototype and the traceability map that + proves it matches the specs. Use when ui-plan.md, flows.md and ux-plan.md are + validated and the flow needs to become bytes: every flow state is instantiated + from the component inventory, every transition is wired per flows.md including + recovery routes, DS tokens are applied per ui-plan.md, cross-state consistency + is enforced, and every prototype element is traced back to a spec entry with + no un-specced additions. Every flow state ships a deep-link hook, because a + state that cannot be driven cannot be audited by state 08 or demonstrated at + the state 09 gate. Reads ui-plan.md, flows.md, ux-plan.md; writes prototype/ + and traceability.md. Depends on ui-planning. No approval gate. +--- + +# Prototype (STATE 07) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 07. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests an assembly. It communicates +> only through the artifact store (`artifacts/`), never directly with other +> skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | `artifacts/ui-plan*.md`, `artifacts/flows*.md`, `artifacts/ux-plan*.md`, `machine_state` | +| Writes | `artifacts/prototype/` (assembled artifact), `artifacts/traceability*.md` | +| Depends on | `ui-planning` (must precede) | +| Approval gate | None | +| Retry ceiling | 3 (unnamed self-loop, assembly fix), then back-transition to `UI_PLANNING` | +| Next states | `SELF_AUDIT` (normal) / `UI_PLANNING` (back-transition: spec insufficient to assemble) / `PROTOTYPE` (self-loop: assembly fix) | + +## Purpose + +Assemble the planned UI + flows into a coherent prototype artifact per the +specifications produced upstream. + +This is the state where specs become bytes, and therefore the state where most +defects are **born** — nearly every audit finding traces to an assembly decision +made here. State 08 exists to catch them; this state exists to not make them. The +two rule sets are complements, not duplicates: +[Build method](#build-method-hardened) below is written from the same defect +record as `skills/08`'s verification method, stated as construction rules rather +than detection rules. + +## Processing steps + +1. **Instantiate** each flow state using the component inventory from + `ui-plan.md`. +2. **Wire transitions** per `flows.md` — including the recovery routes, not just + the happy path. +3. **Apply DS tokens/primitives** per `ui-plan.md`. +4. **Ensure cross-state consistency** — naming, hierarchy, motion. +5. **Produce a traceability map**: prototype element → source spec entry. + +## Build method (hardened) + +Steps 1–5 say *what* to assemble. This section is the contract for *how*, and +each rule below was written by a defect that shipped. Do not substitute a cheaper +method. Codes are cited from plans and logs — index in +[`docs/method-rules.md`](../../docs/method-rules.md). + +### B1 — Emit in chunks, never one giant write + +A whole flow file is ~1,300–2,000 lines. Write the head, then append sections +(~≤300 lines each). Every parallel builder that tried to emit a full file in one +write **crashed mid-response** and lost the work. + +### B2 — Every flow state ships a deep-link hook + +A state reachable only by clicking through four screens is a state that state 08 +cannot drive and the state 09 gate cannot demonstrate. Give every flow state, +variant and error case a query hook (`?view=`, `?state=`, `?sheet=`, `?load=`) +and **record the hook in `traceability.md`** — that table is what makes V1 +checkable instead of assertable. The hook is also the review packet: state 09 +hands the user the same list. + +### B3 — Claim the selector namespace before you use it + +A single-file prototype has one global CSS namespace across every screen. +Prefix per section and scope descendant rules with `>`. Generic names collide +silently and repaint an unrelated panel: a list-row class repainted a hero on +another screen; a descendant selector on one card also matched status and lock +icons elsewhere and inflated them to ~340px; an inline-span badge overflowed its +card and clipped the lines below it. All three were **geometry or screenshot +finds**, invisible to structure. + +### B4 — String and config keys are a namespace too + +Duplicate keys in a locale or config object do not error — the later definition +silently wins. On the extraction run this happened three times to navigation +labels clobbered by later-added categories, and **one locale hid the defect +entirely** because both labels happened to read the same. Sweep for duplicate +keys across every file **and** every locale object before handoff +(`node tools/audit.mjs` runs this sweep; `product.locales` tells it how many +legitimate repeats to expect). + +### B5 — The token layer is the base, not an opt-in + +Set the token on the container (`.screen{font-family:var(--ui-font)}`), never +per-component. An opt-in token layer means anything that forgets to opt in falls +through to whatever the platform picks — that is how **138 instances across 7 +flows** rendered a script on a stack that had no face for it. Then check the +stack itself: CSS falls back **per glyph**, so a stack carrying no face for a +script it must render is a defect even where the token was applied correctly. +Declare every script in `toolkit.config.json` → `product.scripts` so STATE 08 can +check it. + +The same completeness rule holds for **asset registries**. A slug with no crop +entry falls back to the default entry, which frames the wrong part of the asset. +Every slug you reference needs a real entry. + +### B6 — A transition is not wired until its destination paints + +Two failure shapes, both of which pass structural assertions: + +- **Boundary mocks that outlived their boundary.** Eleven live boundary call + sites still routed to a placeholder after every destination existed. Re-check + the whole set whenever another flow ships — a "no mocks left" claim is a dated + claim about one flow, not a property of the set. +- **A state that exists but never becomes visible.** `.view` is + `visibility:hidden` until `.active`; nothing added `.active`; **84/84 DOM + assertions passed against a screen that displayed nothing**, because + `visibility:hidden` keeps layout boxes and accepts programmatic clicks. + +Verify each wired transition by driving it and asserting the destination +**paints** — computed visibility and geometry. + +### B7 — Supersession deletes + +When a revision rebuilds a component, strip the old CSS, strings and JS rather +than leaving them dead. `ui-plan.md`'s **Superseded** table names what goes; the +prototype is where it actually goes. Leftovers are un-specced elements and +violate V2 exactly as much as additions do. Record the strip in +`traceability.md`, **itemised** — the removed selector and string-key list is the +evidence, and a rebuild that cannot produce one did not do the strip. + +### B7b — Honour the harness contract + +Three harnesses read the prototype — `tools/smoke.mjs`, `tools/audit.mjs`, +`tools/stateprobe.mjs` — and they all read it the same way, through +`toolkit.config.json` → `prototype`: + +| Config key | Default | What the prototype must do | +|---|---|---| +| `viewSelector` | `.view` | one element per flow state | +| `activeClass` | `active` | added to exactly the view being shown | +| `screenSelector` | `.screen` | the viewport-sized container | +| `sidSelector` | `#sid` | prints the active view's screen id | +| `minVisibleNodes` | `3` | paint floor — keep it low; an empty state is sparse by design | + +Plus `data-view` and `data-sid` on every view. `data-sid` is what makes registry ↔ +prototype id drift **measurable rather than asserted**: the probe reads what the +page prints and compares it to the id the registry claims. + +Change the convention in the config, not in a tool. A prototype whose views are +invisible to the contract reports as *blank* — which is indistinguishable from the +defect B6 exists to catch. + +### B8 — Self-check before handing to `SELF_AUDIT` + +Do not spend the audit's budget on defects assembly can find: + +```bash +node tools/smoke.mjs "<page>:<view>,<view>" ... +``` + +- `node --check` on the extracted `<script>`. +- Hex inventory against the `ui-plan.md` allowlist — CSS **id selectors** + (`#feed`) are not colours, and review-chrome **files** are excluded + (`toolkit.config.json` → `review.harnessFiles`). The exclusion is by file, so + chrome that lives inside a product file is swept like any other surface. +- Drive every view headless and **read the screenshots**. Renders break with + zero console errors. +- Console sweep, with known-benign entries filtered **by name** + (`audit.benignConsole`) rather than ignored wholesale. +- Read every rendered number and ceremony icon against its own copy once. A + progress fill computed from a 0-based index shows empty on step 1; an + "unlocked!" ceremony drew a **closed** padlock. Both were faithful to their + code and wrong on the screen. + +## Figma assembly — plugin-API traps + +When this state's output includes pushing frames or variables into Figma, three +API behaviours cost a full rebuild once and will again: + +- **F1 — `setBoundVariableForPaint()` silently drops `paint.opacity`** (0.25 → 1, + verified). A binding sweep flattened every alpha in the file. Working recipe: + assign the bound paint → **re-read `node.fills[0]`** → spread the opacity on → + reassign. Spreading the object *returned by* the bind call does not survive + assignment. +- **F2 — a bad lookup fails silently, it does not throw.** + `getVariableByIdAsync('1:3')` returns `null` — it needs the `VariableID:` + prefix — and `setBoundVariableForPaint(p,'color',null)` returns the paint + *unbound* rather than erroring. A wrong id therefore yields unbound paints with + zero errors. Re-read and assert the binding. +- **F3 — `paint.opacity` round-trips as float32** (`0.12` → `0.11999999…`). + Compare with an epsilon. + +## Output + +### `artifacts/prototype/` + +The assembled artifact. Conventions the toolkit settled on: one self-contained +file per flow, a shared player for review packaging (`templates/prototype/` — +copy `run-local.sh`, `serve.py` and `play.html` in, and register the page in +`play.html`'s `FEATURES` array in the same edit), deep-link hooks per B2, and a +demo bar for the states that cannot be reached by data alone. + +### `artifacts/traceability.md` + +Write with structured frontmatter + body so downstream skills and the machine +can validate mechanically. + +```markdown +--- +artifact: traceability +version: trace-<feature>-NN +produced_by: prototype +reads_versions: + requirements-<feature>.md: <version> + ux-plan-<feature>.md: <version> + flows-<feature>.md: <version> + ui-plan-<feature>.md: <version> +feature: <feature/flow id> +--- + +# Traceability — <feature> (<proto-<feature>-NN>) + +Every prototype element traces to a spec entry (V2), and every flow state is +represented (V1). Files: `artifacts/prototype/<file>`. + +## Requirement → task → flow → component → prototype element + +| Req | Task | Flow | UI component | Prototype element / hook | +|---|---|---|---|---| +| R-x1 | TK1 | F1 | <component from ui-plan inventory> | `<selector>`, `<fn()>`, `?hook=` | + +## Flow state → prototype representation (V1) + +| <flow id> node | Representation | Hook | +|---|---|---| +| <state name> | <default entry \| selector \| overlay> | `?view=…` | + +_Every node in `flows.md`, including recovery and non-happy-path states._ + +## Transition → wiring (V4) + +| Flow transition | Wired as | Destination paints | +|---|---|---| +| <from> → <to> (D<n>) | `<fn()>` / `goFlow(...)` | yes — <evidence> | + +## Decision → implementation + +| Decision | Where it lives | +|---|---| +| D-x1 <text> | `<const / selector / guard>` | + +## Superseded — stripped, not left dead (B7) + +| Removed | Superseded by | Selectors / keys stripped | +|---|---|---| +| <component> | <revision> | `.a`, `.b`, `strKey1`, `fnName()` | + +## Un-specced additions + +<none — or each one named, with the spec entry it needs before V2 can pass.> + +## Verification record (B8) + +<node --check · hex inventory · views driven · screenshots read · sweeps run +(duplicate keys, boundary call sites, per-glyph font) · console sweep.> +``` + +## Validation rules (machine-checkable on output) + +- **V1:** Every flow state from `flows.md` is represented in the prototype. +- **V2:** Every prototype element traces to a spec entry — **no un-specced + additions**. +- **V3:** DS token usage matches `ui-plan.md` references. +- **V4:** All wired transitions correspond to defined flow transitions. +- **V5** *(project-hardened, per B2)*: Every flow state in the V1 table carries a + **deep-link hook**. A state that cannot be driven cannot be audited, so V1 is + otherwise unverifiable. +- **V6** *(project-hardened, per B7)*: A superseded component leaves **no dead + selectors, strings or handlers** behind. Leftovers are un-specced elements and + fail V2. + +## Exit conditions + +Validation passes; traceability complete. + +## Failure recovery + +- **V1/V2 failure** → assemble the missing states, or remove the un-specced + additions, then re-validate. **Retry ceiling: 3** on the self-loop. +- **Repeated spec insufficiency** → back-transition to `UI_PLANNING`. "The spec + does not say" is a routing signal, not a licence to invent. An invented value + assembled here becomes a frozen number nobody owns. +- A missing upstream artifact → back-transition to the state that owed it. Do + not assemble around a missing spec. + +## Approval gate + +None. The prototype is not shown to the user from this state — `SELF_AUDIT` +gates it first, and `USER_REVIEW` owns the presentation. + +## Recorded failure modes + +Each entry is a defect class that actually shipped from this state on the run +this toolkit was extracted from. They are the evidence for +[Build method](#build-method-hardened). + +### A. Assembly defects + +| Case | What happened | Build rule | +|---|---|---| +| **View never activated** | The screen never became visible; **84/84 DOM assertions passed** against a screen displaying nothing. `visibility:hidden` keeps layout boxes and accepts programmatic clicks. | B6 | +| **11 stale boundary call sites** | Boundary mocks still routed to a placeholder although every destination existed. One carried a source comment naming the destination flow as "still todo" — written before that flow shipped, never revisited. | B6 | +| **Class collisions** | A list-row class repainted a hero on another screen; a descendant selector inflated status/lock icons to ~340px; an inline badge overflowed its card and clipped the lines below. | B3 | +| **Duplicate string keys** | Navigation labels silently clobbered by later definitions in the same object, twice. **One locale hid the second one completely**; only the other showed it. | B4 | +| **Opt-in token layer** | The script→font token applied per component over a foreign base → **138 instances across 7 flows** on an arbitrary platform fallback. Then the base stack itself carried **no face for that script at all**. | B5 | +| **Incomplete asset registry** | Asset slugs with no crop entry fell back to the default frame — twice, in two different flows, with different slugs. | B5 | +| **Off-by-one and reversed semantics** | Progress fill computed as `i / N` showed empty on step 1; an unlock ceremony drew a **closed** padlock under "🔓 unlocked!". | B8 | +| **Sheet and scroll geometry** | `translateY(105%)` failed to clear a sheet shorter than its `max-height`, so a *closed* sheet bled back in; `scrollIntoView()` scrolled an `overflow:hidden` ancestor and pushed the header out of frame; a card ellipsized its most important word. All **screenshot-only** finds. | B8 | + +**The lesson, stated once:** the assertions you write test the structure you +were thinking about. The screenshot tests the screen. + +### B. Build-ops + +| Case | What happened | Build rule | +|---|---|---| +| **Parallel builder crash** | Every agent emitting a whole file in one write died with *"connection closed mid-response"*; the chunked retry succeeded. | B1 | +| **Figma alpha flattening** | A binding sweep silently dropped `paint.opacity` on every bound paint — all base frames had to be rebuilt. | F1 | +| **Silently unbound paints** | A `VariableID:`-less lookup returned `null` and the bind call accepted it without throwing, producing unbound paints and zero errors. | F2 | + +### C. Scope discipline + +- **Chrome that a ruling scoped to specific screens stays on those screens.** On + the extraction run the locale toggle was ruled onto three surfaces only, while + the locale itself inherited through the shared state record. Adding the control + elsewhere because it is convenient in a builder is an un-specced addition and + fails V2. +- **A spec that contradicts a ruled decision is not resolved here.** Assemble the + ruled behaviour, ship the alternative behind a demo toggle, and raise the open + for `USER_REVIEW`. Deciding it in the assembly hides the conflict inside bytes. diff --git a/design-toolkit/skills/08-self-audit/README.md b/design-toolkit/skills/08-self-audit/README.md new file mode 100644 index 0000000..a87c831 --- /dev/null +++ b/design-toolkit/skills/08-self-audit/README.md @@ -0,0 +1,27 @@ +# Skill: `self-audit` — STATE 08 + +**Adversarially review the prototype before any user attention is spent.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 08 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-08--self_audit) + +[← skills/](../README.md) · prev ← [07 prototype](../07-prototype/README.md) · next → [09 user-review](../09-user-review/README.md) + +| Field | Value | +|---|---| +| Machine state | `SELF_AUDIT` | +| Reads | `prototype/`, `traceability`, all upstream | +| Writes | `audit-report-<feature>.md` | +| Depends on | prototype | +| Approval gate | None — the machine gating itself | +| Retry ceiling | 3 (`L_AUDIT_FIX`) | +| Next states | `USER_REVIEW` (pass) / `REVISION` (fail) / self-loop | + +## The rule that matters most here + +Every check is **rendering-class** — computed visibility and geometry, never DOM presence. A failing probe is a hypothesis until confirmed at source. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/08-self-audit/SKILL.md b/design-toolkit/skills/08-self-audit/SKILL.md new file mode 100644 index 0000000..defea89 --- /dev/null +++ b/design-toolkit/skills/08-self-audit/SKILL.md @@ -0,0 +1,260 @@ +--- +name: self-audit +description: >- + State 08 of the AI Product Design Agent workflow. Adversarially reviews the + built prototype against every upstream spec and quality dimension before any + user attention is spent on it, and emits a pass/fail verdict with findings + classified blocker/major/minor. Use when a prototype exists and has not yet + been shown: conformance to requirements → flows → UI plan is checked, the + accessibility and reduced-motion audit is executed against the UX strategy, + non-happy-path states are proven reachable, and every acceptance criterion is + marked met or unmet with evidence. Every check is rendering-class — computed + visibility and geometry, never DOM presence — and every failing probe is + treated as a hypothesis until confirmed. Reads prototype/ plus all upstream + artifacts; writes audit-report.md. Depends on prototype. No approval gate — + this is the machine gating itself. +--- + +# Self-Audit (STATE 08) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 08. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests a self-audit. It +> communicates only through the artifact store (`artifacts/`), never directly +> with other skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | `artifacts/prototype/`, `artifacts/traceability*.md`, all upstream artifacts, `machine_state` | +| Writes | `artifacts/audit-report*.md` | +| Depends on | `prototype` (must precede) | +| Approval gate | None — the machine gates itself here, before spending user attention | +| Retry ceiling | 3 (`L_AUDIT_FIX`, `SELF_AUDIT` ↔ `REVISION`), then escalate into `L_REVISION` accounting | +| Next states | `USER_REVIEW` (verdict `pass`) / `REVISION` (verdict `fail`) / `SELF_AUDIT` (self-loop, bounded re-audit after in-place minor fix) | + +## Purpose + +Machine self-review of the prototype against all upstream specs and quality +dimensions **before showing the user**. A `fail` verdict is a normal outcome, +not an error. + +The audit's job is to find what the builder missed. That means it must be +adversarial toward its **own instrument** as much as toward the prototype — +see [Verification method](#verification-method-hardened), which is not optional +guidance but part of this state's contract. + +The harness is [`tools/audit.mjs`](../../tools/audit.mjs): it drives what +`reference/audit-plan.json` (or the hooks in `reference/state-machines.json`) +names, and reads its floor, palette, viewport and scripts from +`toolkit.config.json`. It produces evidence, not a verdict — the verdict is this +state's, written after the screenshots have been read. + +## Processing steps + +1. Check prototype **conformance** to requirements → flows → UI plan. +2. Run the **accessibility + reduced-motion** audit against `ux-plan.md`. +3. Verify **non-happy-path coverage** is present **and reachable**. +4. Detect **inconsistencies, orphan elements, unmet acceptance criteria**. +5. Classify findings by severity (`blocker` / `major` / `minor`). +6. Emit a **pass/fail verdict**. + +## Verification method (hardened) + +Steps 1–4 above say *what* to check. This section is the contract for *how*, and +it exists because each rule below was written by a defect a passing assertion +suite did not see. Do not substitute a cheaper method. Codes are cited from logs +and gate records — index in [`docs/method-rules.md`](../../docs/method-rules.md). + +### M1 — Every check is rendering-class + +Assert **computed visibility and geometry**, never DOM presence. +`getComputedStyle` visibility/display/opacity, `getBoundingClientRect` width, +height and position inside the viewport. A node can exist, lay out, and accept +a programmatic click while painting nothing. + +### M2 — Look at the render + +Screenshot review is a **required** audit step, not a supplement. Capture every +screen across locale × theme × reduced-motion × state (one pass per combination +in the audit plan), and review the images. A finding class that only a human eye +catches is not thereby out of scope. + +### M3 — A failing probe is a hypothesis, not a finding + +Confirm every failure at source before writing it into the report. Correct the +harness and re-run; do not waive, and do not report unconfirmed. See +[Recorded failure modes §B](#b-harness-false-positives) for the known +false-positive classes to rule out first. + +### M4 — Sweep the source, not just the surface + +Duplicate keys in string/config objects, stale placeholder routes, and per-glyph +font fallback are invisible to both assertions and screenshots in at least one +locale or theme. Run explicit sweeps: duplicate keys across all files **and** +every locale object; every navigation call site resolved to a destination that +**paints**; every element carrying a declared script verified on a stack that +actually contains that script's face. + +### M5 — The verdict is scoped to the bytes it audited + +An audit of record is invalidated by any later change to the prototype. Targeted +assertions run during a revision round are **not** an audit. Record the exact +version audited in `reads_versions`; if the frozen bytes have moved since, +re-run before any gate depends on the verdict. + +### M6 — Record, do not silently resolve + +A conflict between a project acceptance criterion and an external standard, or +between two approved artifacts, is **recorded as a finding with a +recommendation** — never silently passed and never silently changed. Silently +editing one of two disagreeing sources hides the disagreement rather than +resolving it. + +## Output — `artifacts/audit-report.md` + +Write with structured frontmatter + body so downstream skills and the machine +can validate mechanically. + +```markdown +--- +artifact: audit-report +version: <audit-<feature>-NN> +produced_by: self-audit +reads_versions: + prototype: <proto-<feature>-NN — the exact bytes audited> + requirements.md: <version> + flows.md: <version> + ui-plan.md: <version> + traceability.md: <version> +feature: <feature/flow id> +verdict: pass | fail +--- + +# <audit id> — <feature> — verdict: <PASS | FAIL> + +## Verdict + +<pass|fail> — <N> / <N> acceptance criteria met. <rationale> + +## Method + +<what was actually run: screens driven, runs, assertion count, screenshots +captured, sweeps executed. Name the check class per M1 — computed visibility +and geometry, not DOM presence.> + +## Findings + +| ID | Sev | Finding | Caught by | Status | +|---|---|---|---|---| +| AF-1 | blocker | <defect> | screenshot \| assertion \| sweep | fixed in-loop | +| AF-2 | major | <defect> | <method> | open | +| AF-3 | minor | <defect> | <method> | recorded → debt #N | + +## Conformance matrix + +| AC | Requirement | Met | Evidence | +|---|---|---|---| +| AC1 | <text> | met | <screen + probe or screenshot ref> | +| AC7 | <text> | unmet | <what is missing> | +| AC9 | <text> | waived | <who waived, why, recorded where> | + +## Harness corrections + +<per M3 — probes that failed and turned out to be the instrument's fault, with +the correction made. Recorded, because an uncorrected harness re-reports them +next run.> + +## Known limitations + +<carried to USER_REVIEW for transparent presentation> +``` + +## Validation rules (machine-checkable on output) + +- **V1:** Every acceptance criterion from `requirements.md` is marked + `met` / `unmet` / `waived` **with evidence**. +- **V2:** Zero unresolved `blocker` findings to pass. +- **V3:** The accessibility audit was **executed**, not skipped. +- **V4:** Verdict ∈ {`pass`, `fail`} with rationale. +- **V5** *(hardened, per M1)*: Every check in the method record is + rendering-class — computed visibility or geometry. A report whose evidence is + DOM presence alone does not satisfy V1. +- **V6** *(hardened, per M5)*: `reads_versions.prototype` matches the + currently frozen prototype bytes. A stale verdict is not a verdict. + +## Exit conditions + +- `pass` → proceed to `USER_REVIEW`. +- `fail` → route to `REVISION` with findings attached. + +## Failure recovery + +- A `fail` verdict is a **normal outcome**, not an error → deterministic route + to `REVISION` with the findings attached. +- Minor findings may be fixed in place and re-audited via the bounded + `SELF_AUDIT` self-loop (`L_AUDIT_FIX`, ceiling 3), then escalate into + `L_REVISION` accounting. +- Internal audit error (e.g. a missing input artifact) → back-transition to the + state that owed the missing artifact. Do not audit around a missing spec. + +## Recorded failure modes + +Each entry below is a defect class that actually shipped past a green suite on +the run this toolkit was extracted from. They are the evidence for +[Verification method](#verification-method-hardened). + +### A. Defects invisible to structural assertions + +| Case | What happened | Method rule | +|---|---|---| +| **View never painted** (blocker) | A screen **never became visible** — the view container is `visibility:hidden` until activated and nothing activated it — and **84/84 DOM assertions still passed**, because `visibility:hidden` keeps layout boxes and accepts programmatic clicks. Caught by looking at a screenshot. | M1, M2 | +| **Geometry defects, four in one flow** | A closed sheet bleeding back into the screen; `scrollIntoView()` scrolling an `overflow:hidden` ancestor and pushing the header out of frame; a label ellipsized to its least useful word; an asset crop gap. **Four of that flow's six real defects were screenshot-only finds.** | M2 | +| **Label and glyph out of sync** (major) | A navigation re-cut relabelled a slot but every file kept the **old glyph** — the tab read one thing under the icon for another. Pre-existing in two **already-approved** files, so the re-cut would have propagated it. | M2 | +| **Duplicate keys** (major) | One file defined two navigation labels **twice** per locale object; the later definition silently clobbered them. **One locale hid it entirely** — only the other showed it. | M4 | +| **Stale boundary routes** (major) | **Eleven live boundary call sites** still routed to a placeholder although every destination existed. An earlier *"no boundary mocks left"* claim had been written about one flow's mocks and did not hold for the set. | M4 | +| **Script on the wrong stack** (major) | The base font token was the foreign stack and the script token was **opt-in per component**, so anything that did not opt in rendered on an arbitrary OS fallback — **138 instances across 7 flows**. Then the remaining ~40 explicit cases turned out to be the stack itself: it carried **no face for that script at all**. CSS falls back **per glyph**, so the fix is verified per glyph, in every locale. | M4 | +| **Class collisions** | A descendant selector also matched status and lock icons and inflated them to ~340px; an inline-span badge overflowed its card and clipped the lines below. Both found by **geometry probes**, not by structure. | M1 | + +**The lesson, stated once:** a DOM-assertion suite is not a substitute for +looking at the render, and "N/N assertions passed" is a statement about the +suite, not about the product. + +### B. Harness false-positives + +One final audit's first run reported **60 failures; only 3 were real.** A later +state probe reported **37 failures and all 37 were the harness.** Rule each of +these classes out before reporting: + +| Reported | Reality | +|---|---| +| Hundreds of overflow violations | Inside horizontal **scroll rails** — `genuinelyClipped: 0`. Check overflow **ancestry**. | +| Off-palette hexes across 8 files | The demo bar and device bezel — **harness chrome**, not app surface. Chrome that lives in its own file is excluded by `review.harnessFiles`; the sweep is file-level, so chrome embedded in a product file is not exempt and should not be. | +| A `#FEED` colour violation | The CSS **id selector** `#feed`. A hex scanner must not read selectors. | +| A foreign-stack token used 27–47× per file | The intended architecture for numerals; the check itself was wrong. | +| A 20px tap target | `::after{inset:-12px}` — an explicit hit-area expansion, commented in source. Measure the **hit area**, not the box. | +| A clipped `<img>` | A deliberate crop — an oversized asset inside `overflow:hidden`. | +| A screen failing to render in one theme | **Timing flake**; renders at every settle when measured. Re-run before reporting. | +| Console errors on nearly every page | An offline webfont CDN and a missing `favicon.ico` — neither belongs to the build. Filter benign entries **by name** (`audit.benignConsole`), never wholesale. | +| Empty states "fail to paint" | A visible-node threshold tuned to a busy screen. An empty state is **sparse by design**. | +| A hook that renders nothing | The hook named an id the catalogue does not contain and the page threw. A wrong fixture is not a product defect. | + +Each was corrected in the harness and the audit re-run — **not waived**. + +### C. Recorded, not resolved + +- **Interactive targets between the external standard and the project's own + claim.** ~140 elements on 20 screens passed WCAG 2.5.8 **AA** (24px) and missed + the project's own acceptance criterion of 44px. Pre-existing across four + approved gates; raising them would restyle 20 approved screens. A **product + decision** — recorded as debt with a recommendation, per M6. +- **Two approved deliverables disagreeing on a data value.** Reported as a + conflict for a one-line ruling. Silently editing one to hide it would be worse + than the conflict. + +## Approval gate + +None. This is the machine gating itself before spending user attention. The +`USER_REVIEW` gate is the next state's concern; the **Known limitations** +section of this report is what that gate presents. diff --git a/design-toolkit/skills/09-user-review/README.md b/design-toolkit/skills/09-user-review/README.md new file mode 100644 index 0000000..4292da7 --- /dev/null +++ b/design-toolkit/skills/09-user-review/README.md @@ -0,0 +1,27 @@ +# Skill: `user-review` — STATE 09 + +**Present the audited prototype and capture a structured human verdict.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 09 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-09--user_review) + +[← skills/](../README.md) · prev ← [08 self-audit](../08-self-audit/README.md) · next → [12 flow-visualization](../12-flow-visualization/README.md) when `handoff_required`, else [11 final-output](../11-final-output/README.md) + +| Field | Value | +|---|---| +| Machine state | `USER_REVIEW` | +| Reads | `prototype/`, `audit-report` | +| Writes | `review-record-<feature>.md` | +| Depends on | self-audit | +| Approval gate | **Primary User Approval Gate** — the central human gate | +| Retry ceiling | 2 clarification rounds | +| Next states | `FLOW_VISUALIZATION` / `FINAL_OUTPUT` / `REVISION` / `REQUIREMENT_ANALYSIS` | + +## The rule that matters most here + +**Run Local, never a static preview** (V4) — the player URL is recorded. An approval is scoped to the bytes it saw, and the freeze hashes prove which. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/09-user-review/SKILL.md b/design-toolkit/skills/09-user-review/SKILL.md new file mode 100644 index 0000000..3e122f7 --- /dev/null +++ b/design-toolkit/skills/09-user-review/SKILL.md @@ -0,0 +1,325 @@ +--- +name: user-review +description: >- + State 09 of the AI Product Design Agent workflow, and the machine's central + human gate. Presents the audited prototype to the user against a running local + server and captures a structured approve / request-changes / reject decision. + Use when a prototype has passed self-audit and needs a human verdict: the + prototype is served with Run Local and reviewed through the player, known + limitations from audit-report.md are presented rather than laundered, every + change request is structured into a spec-linked item with a target state, and + the outcome is recorded against the exact frozen bytes it saw — an approval is + scoped to the artifact versions it reviewed, and any later change reverts the + gate to pending. Reads prototype/ and audit-report.md; writes review-record.md. + Depends on self-audit. Approval gate: Primary User Approval Gate. +--- + +# User Review (STATE 09) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 09, +> §5 Approval Gates, §7 Loop Logic, §8 Completion Rules. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests a review. It communicates +> only through the artifact store (`artifacts/`), never directly with other +> skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | `artifacts/prototype/`, `artifacts/audit-report*.md`, `machine_state` | +| Writes | `artifacts/review-record*.md` | +| Depends on | `self-audit` (must precede — the machine gates itself before spending user attention) | +| Approval gate | **Primary User Approval Gate** — the central human gate of the machine | +| Retry ceiling | Clarification sub-prompt bounded to **2 rounds**; the surrounding `L_REVISION` loop is **3 full cycles** → `HALT_BLOCKED` | +| Next states | `FINAL_OUTPUT` (`approve`) / `REVISION` (`request-changes`) / `REQUIREMENT_ANALYSIS` (`reject` — direction wrong at root) / `HALT_BLOCKED` (user unavailable) | + +## Purpose + +Present the audited prototype to the user and capture structured approval or +change requests. + +This state spends the one resource the machine cannot manufacture: human +attention. Everything upstream exists to make that attention cheap — the audit +runs first so the user never debugs, and the packet is built so the user never +hunts. Everything downstream depends on this record being **exact**: `REVISION` +routes from it, and `FINAL_OUTPUT` §8 rule 2 requires an `approve` *scoped to +the final frozen versions*. + +## Processing steps + +1. **Run Local (default).** Serve `prototype/` over local HTTP and open the + player entry point. If a server is already listening → **Refresh Run Local**: + reuse it and reopen the player. Standard command: + `prototype/run-local.sh [port]` (default **8765**). **Record the player URL.** +2. **Package** prototype + audit summary for human review. Review happens + against the *running* prototype, never static files. +3. **Present known limitations** transparently, from `audit-report.md`. +4. **Capture** the user response: `approve` / `request-changes` / `reject`. +5. **Structure** change requests into actionable, spec-linked items with a + target state. +6. **Record** the review outcome. + +## Review method (hardened) + +Steps 1–6 say *what*. This section is the contract for *how*, and each rule was +written by something that went wrong at a real gate. Codes are cited from logs +and records — index in [`docs/method-rules.md`](../../docs/method-rules.md). + +### G1 — Run Local, never a static preview + +`run-local.sh` serves the prototype tree through `serve.py` on the port named in +`toolkit.config.json` → `review.port` (default **8765**) and opens **`play.html`** +— the player with the sidebar (feature list, walkthrough progress, *open +standalone*). The sidebar **is** the intended review chrome; opening a raw page +bypasses it. The script is idempotent and replaces a plain server on the port +with the live-reload one. All three files ship in +[`templates/prototype/`](../../templates/prototype/); STATE 07 copies them into +the prototype directory. + +Two operational facts that silently degrade the review if missed: + +- **A new prototype page must be registered in `play.html`'s `FEATURES` array** + or it never appears in the sidebar and the user reviews the flow set minus + the new flow. +- **Live reload is gated on workflow state** — active only while the top-level + `current_state` in `state/machine_state.yaml` is `USER_REVIEW`. Outside review + the same server serves plain pages. Do not diagnose "reload is broken" without + checking the state file. + +Deep-link into a flow with `play.html#<feature>`; the player splits +`#id?query` so a hook survives into the stage iframe. + +### G2 — The packet is the hook list + +State 07's traceability table (B2) already names a deep-link hook for every flow +state, variant and error case. **That table is the review packet.** Ship it with +the verdict request so the user can reach the non-happy-path states directly +instead of clicking toward them. A state the user cannot reach in one step is a +state that gets approved unseen. + +### G3 — An approval is scoped to the bytes it saw + +§5: *"An approval is scoped to the artifact versions it saw. If artifacts change +after approval, the gate reverts to `pending`."* §8 rule 2 says the same at +completion. Therefore: + +- Freeze at the moment of approval and **record the sha256 of every approved + file** in `review-record.md`. +- Record `reads_versions` in frontmatter — the exact prototype version and the + audit of record. A record that names its versions only in prose is not + machine-checkable. +- If the audit of record pre-dates the reviewed bytes, that is a **waiver**, not + a detail. Name it and give it a rider debt item (see G6). + +### G4 — Classify a post-approval delta before you ask about it + +Bytes will move after approval. Classify first, because the two classes need +different things from the user: + +| Class | Evidence required | What to ask for | +|---|---|---| +| **Bug-fix only** | Byte-level proof: token/hex inventory identical, diff confined to named regions, and the pre-fix file **reconstructed from the inverse delta hashing back to the approved sha** | A one-line **scope confirm** | +| **Feature delta** | The new behaviour, plus what it changes in the approved surface | A **ruling** — the gate is `pending` until it lands | + +Do not present a feature delta as a bug fix to avoid re-opening a gate. The model +to copy is a record whose frontmatter names the bytes it *reviewed* and states +plainly, in the outcome, the version the approval is *scoped to* when the two +differ. That is the correct shape; it is the **silent re-freeze** that is the +defect. + +### G5 — Present limitations; do not launder them + +The `Known limitations` section of `audit-report.md` is this state's input, not +its embarrassment. An acceptance that carries qualifications is recorded **with +its qualifications**. A clean-looking record of a qualified acceptance is a +false record, and it is the artifact `FINAL_OUTPUT` will later be checked +against. + +### G6 — Every waiver names its rider + +A validation may be waived at the gate. It may not be waived *silently*. Each +waiver records: which rule, why, who granted it, and **the debt item it rides +on**. On the extraction run, `FINAL_OUTPUT` V2 (audit currency) and V3 +(traceability) were both waived at one gate; both became tracked debt and both +were closed the next day — that worked **because they were written down**. + +### G7 — Keep the pass count honest + +`pass N = 1 + revision rounds delivered`. One round is one prototype rebuild, +however many sub-lettered asks it folds. Bump it in **every** place that states +it — the progress snapshot, the flow row and the recommendation list — in the +same edit, or they drift. Three pass counts were found stale at one gate and had +to be corrected before it could close. + +### G8 — Ambiguity is bounded, not absorbed + +Ambiguous feedback → a targeted clarification sub-prompt, **bounded to 2 +rounds**. After that, record it as a `non-blocking` note rather than guessing. +A guess recorded as a requirement becomes a spec nobody chose. + +## Output — `artifacts/review-record.md` + +Write with structured frontmatter + body so downstream skills and the machine can +validate mechanically. **This frontmatter is canonical** — on the extraction run +the records drifted into two different shapes, and the later one dropped +`reads_versions` entirely, which is exactly the field `FINAL_OUTPUT` needs to +check §8 rule 2. + +```markdown +--- +artifact: review-record +version: review-<feature>-NN +produced_by: user-review +reads_versions: + prototype: <proto-<feature>-NN — the exact bytes reviewed> + audit-report.md: <audit-<feature>-NN> +feature: <feature/flow id> +date: <YYYY-MM-DD> +gate: PrimaryUserApprovalGate +verdict: APPROVED | REQUEST-CHANGES | REJECTED +player_url: http://localhost:8765/play.html#<feature> +--- + +# Gate record — <feature> + +## Decision + +**<VERDICT>** on the **Nth presentation**, <date>. + +User instruction, verbatim: *"<quote>"*. + +| Field | Value | +|---|---| +| Flow | <flow + screen ids> | +| Prototype at decision | `<proto-…>` | +| Audit of record | `<audit-…>` — <PASS N / N> | +| Reviewed at | `<player URL>` (Run Local rule V4) | +| Passes to approval | <N> | + +## What was approved + +<the ratified behaviour, decision by decision — this is what REVISION and +FINAL_OUTPUT will treat as settled.> + +## Verification at approval + +| Check | Result | +|---|---| +| Audit | <PASS N / N ACs> | +| Assertions (rendering-class) | <N / N> | +| Console | <N errors> | +| Screenshots | <N> | + +## Change requests (if `request-changes`) + +| ID | Verbatim ask | Target state | Spec link | Status | +|---|---|---|---|---| +| CR1 | *"<quote>"* | `UI_PLANNING` | R-x3 / F2 | open → revision-log | + +_No request is dropped (V3). Anything not actioned is recorded as `deferred` +with a reason._ + +## Deltas ratified by this decision + +| # | Delta | Class | Evidence | +|---|---|---|---| +| 1 | <change on top of frozen bytes> | bug-fix only \| **feature** | <inverse-delta hash / behaviour> | + +## Known limitations presented + +<verbatim from audit-report.md — carried, not laundered (G5).> + +## Opens carried forward + +| ID | Question | Ships as | +|---|---|---| + +## Validations waived + +| Rule | Why | Granted by | Rides on | +|---|---|---|---| + +## Freeze hashes + +| Deliverable file | sha256 | +|---|---| +``` + +## Validation rules (machine-checkable on output) + +- **V1:** Outcome ∈ {`approve`, `request-changes`, `reject`}. +- **V2:** If `request-changes`, **≥1** change request, each linked to a target + state. +- **V3:** **No change request silently dropped** — every captured ask appears as + `resolved`, `open` or explicitly `deferred` with a reason. +- **V4:** **Run Local executed** — the review was conducted against a served + prototype and the **player URL is recorded in `review-record.md`**, not + against file previews. +- **V5** *(hardened, per G3)*: `reads_versions` names the exact + prototype and audit versions, and **freeze hashes are recorded** for every + approved file. An approval that cannot name its bytes cannot satisfy §8 + rule 2. +- **V6** *(hardened, per G6)*: Every waived validation names its rider + debt item. A waiver with no rider is a silent waiver. + +## Exit conditions + +User decision captured and structured. + +## Failure recovery + +- **User unavailable** → `HALT_BLOCKED`. State is fully persisted and resumable + at the exact state; this is not a failure. +- **Ambiguous feedback** → targeted clarification sub-prompt, bounded to **2 + rounds**, else record as a `non-blocking` note (G8). +- **Conflicting change requests** → they are surfaced at the **Conflict + Mini-Gate** in `REVISION`, not resolved here. This state records the conflict + faithfully, including both sides. +- **`reject`** → `REQUIREMENT_ANALYSIS`. Reject means the direction is wrong at + the root, which is an upstream fault, not a revision. +- **`L_REVISION` ceiling (3 full cycles)** → `HALT_BLOCKED` with an escalation + summary of unresolved items. The loop can never silently continue. + +## Approval gate + +**Primary User Approval Gate** — `USER_REVIEW` → `FINAL_OUTPUT`. It blocks +shipping an unapproved deliverable. Grantor: the **user**, never the machine. +On deny: → `REVISION` or `REQUIREMENT_ANALYSIS`. + +Gate rules that bind this state: + +- No forward transition through the gate without `C_APPROVED(gate)`. +- Gate state persists in `machine_state.approvals` and is resumable. +- **The gate reverts to `pending` when approved artifacts change** — the + stale-approval anti-pattern is one of five the machine explicitly forbids. + +## Recorded failure modes + +### A. Gate integrity + +| Case | What happened | Rule | +|---|---|---| +| **Bytes moved after approval, three times in one session** | Two bug-fix deltas and one **feature** delta — a whole screen rebuilt as a new surface — all landed on top of already-frozen deliverables. Each needed a scope confirm that **no written contract required** at the time; the feature one needed a ruling, not a confirm. | G3, G4 | +| **A stale machine record while the machine reported shipping** | `machine_state.yaml` sat **two days stale** — still naming an earlier flow at `USER_REVIEW / gate: pending` — while four §8 conditions were unmet. The gate record is what `FINAL_OUTPUT` trusts; if it is not written at decision time it is written from memory later. | G3 | +| **Pass counts stale in three places at once** | Three flows each understated their presentation count by two to four rounds. All three had to be corrected before the gate could close. | G7 | +| **Frontmatter drift** | The records split into two shapes; the most recent one **dropped `reads_versions` entirely**, naming its prototype and audit only in a body table. That is the exact field `FINAL_OUTPUT` needs to check "approval scoped to the final frozen versions". | V5 | +| **V4 evidence not actually recorded** | Three of six records named the player and hook in prose; **none recorded a player URL**. V4 asks for the URL specifically, so the rule was satisfied in practice and unevidenced in the artifact. | V4, G1 | + +### B. Packaging + +- **A flow missing from the player sidebar** is a flow the user does not review. + New pages must be registered in `play.html`'s `FEATURES` array, in the same edit + that creates the page. +- **Live reload gated on `current_state`** — outside `USER_REVIEW` the server + serves plain pages. Check the state file before calling reload broken. +- **Reviewing raw pages bypasses the player chrome.** This was a direct user + correction, and it is why V4 exists. + +### C. Waivers that worked + +`FINAL_OUTPUT` **V2** (five flows past their audit of record) and **V3** (no +traceability matrix for one batch) were both waived at a gate and both recorded as +debt with a named owner and a fix. Both were closed the next day — the audit +re-run against the frozen bytes, the matrix backfilled. **The waiver was not the +problem; it would have been the silence.** That is the whole of G6. diff --git a/design-toolkit/skills/10-revision/README.md b/design-toolkit/skills/10-revision/README.md new file mode 100644 index 0000000..37a6001 --- /dev/null +++ b/design-toolkit/skills/10-revision/README.md @@ -0,0 +1,27 @@ +# Skill: `revision` — STATE 10 + +**Route findings and change requests back to the state that caused them.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 10 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-10--revision) + +[← skills/](../README.md) · **not in the line** — entered from [08 self-audit](../08-self-audit/README.md) (`fail`) or [09 user-review](../09-user-review/README.md) (`request-changes`), returns through 08 + +| Field | Value | +|---|---| +| Machine state | `REVISION` | +| Reads | `audit-report` and/or `review-record` | +| Writes | `revision-log-<feature>.md` | +| Depends on | self-audit **or** user-review | +| Approval gate | None to start; **Conflict Mini-Gate** before dispatching a conflict | +| Retry ceiling | `L_REVISION` 3 full cycles | +| Next states | any upstream state / `SELF_AUDIT` / `HALT_BLOCKED` | + +## The rule that matters most here + +The only state that writes work into other states. Route the **class**, not the instance — and write the loop counter down every cycle, or the ceiling is not a ceiling. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/10-revision/SKILL.md b/design-toolkit/skills/10-revision/SKILL.md new file mode 100644 index 0000000..887a460 --- /dev/null +++ b/design-toolkit/skills/10-revision/SKILL.md @@ -0,0 +1,396 @@ +--- +name: revision +description: >- + State 10 of the AI Product Design Agent workflow, and the only state that + writes work into other states. Merges self-audit findings and user change + requests into one change set, triages each item to the state where the fault + was introduced, dispatches in dependency order, and tracks every item to + `resolved` or explicitly `deferred`. Use when a prototype fails audit or a + review gate returns `request-changes`: each item is routed by root cause + rather than by where the symptom is visible, the defect *class* is swept + rather than the reported instance, conflicts go to the Conflict Mini-Gate + instead of being settled inside the bytes, superseded components are stripped + as part of the change, the rebuild returns through `SELF_AUDIT`, and the loop + counter is written down every cycle so the ceiling is real. Reads + audit-report.md and review-record.md; writes revision-log.md. Depends on + self-audit or user-review. Approval gate: none to start; Conflict Mini-Gate + before dispatching a conflict. +--- + +# Revision (STATE 10) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 10, +> §3 Transitions, §5 Approval Gates, §6 Retry Logic, §7 Loop Logic, §8 Completion +> Rules. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests a revision. It communicates +> only through the artifact store (`artifacts/`), never directly with other +> skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | `artifacts/audit-report*.md` and/or `artifacts/review-record*.md`, all artifacts, `machine_state` | +| Writes | `artifacts/revision-log*.md` | +| Depends on | `self-audit` (verdict `fail`) **or** `user-review` (`request-changes`) — a change set with no named source is scope creep, not a revision | +| Approval gate | **None to start.** **Conflict Mini-Gate** blocks dispatch of conflicting change requests. Re-approval happens when the loop returns to `USER_REVIEW` | +| Retry ceiling | `L_REVISION` **3 full cycles** → `HALT_BLOCKED` + escalation summary. `L_AUDIT_FIX` **3** (`SELF_AUDIT` ↔ `REVISION`) → escalates *into* `L_REVISION` accounting | +| Next states | any upstream state (root-cause dispatch) / `SELF_AUDIT` (rebuild complete) / `HALT_BLOCKED` (ceiling exceeded) | + +## Purpose + +Apply audit findings and/or user change requests by routing work back to the +correct upstream state(s), tracking each change to closure. + +Every other state produces its own artifact. This one **produces work for other +states**, which makes it the only place where a misrouted fault becomes three +wasted cycles instead of one. It is also the primary product loop: §7 requires +`L_REVISION` to terminate by user `approve` or by ceiling, and forbids it +continuing silently. + +On the run this toolkit was extracted from, this state ran **13 times across five +flows** with no written dispatch rule — which upstream state owned an incoming ask +was decided ad hoc each round. The method below is that rule. + +## Processing steps + +1. **Merge** findings + change requests into a single change set. +2. **Triage** each change to its root-cause state (requirements? flow? UI? + prototype?). +3. **Order** changes by dependency (upstream before downstream). +4. **Dispatch** to target state(s) via back-transition; on return, re-run + downstream states as needed. +5. **Track** each change item to `resolved` / `deferred` with reason. + +## Revision method (hardened) + +Steps 1–5 say *what*. This section is the contract for *how*, and each rule was +written by something that went wrong in a real revision round. Codes are cited +from logs — index in [`docs/method-rules.md`](../../docs/method-rules.md). + +### R1 — Merge first, dedup against `seen_changes` + +One change set per cycle, drawn from both sources at once. §7's loop invariant +requires deduplication against a `seen_changes` set so a rejected change cannot +re-enter the loop endlessly. When an item returns, classify it: + +- **Same item, no new evidence** → drop it and cite the prior decision. +- **Same item, new evidence** → re-open it and name the evidence. + +An item that reappears with no evidence and no citation is the loop running on +its own exhaust. + +### R2 — Route the class, not the instance + +The reported defect is a sample. Before dispatch, state the **class** and sweep +for it. + +One defect was diagnosed as six selectors rendering a script on the wrong font +stack in one file. Three were patched; **the class was never swept**. The actual +root cause — the script token was an *opt-in* layer under a foreign base — +surfaced **24 days later at 138 instances across 7 flows**, and behind it a second +defect: the base stack carried no face for that script at all. One class, one root +cause, three patched selectors, twenty-four days. + +The class may also live in the **machine** rather than the artifact. One change +request read "colours deviate from the DS"; the root cause was that the audit had +*no token-conformance rule* — it checked DS presence, never non-DS absence. The +dispatch was therefore **two** states: `SELF_AUDIT` (a new permanent rule) and +`PROTOTYPE` (the values). Fixing only the values would have left the class open. + +Record the sweep count in the log. "Fixed in 1 file" and "fixed in 11 files" are +different claims. + +### R3 — Root cause is where the fault was introduced, not where it is visible + +Everything is visible in the prototype. That is not evidence it belongs to +`PROTOTYPE`. + +| The ask is… | Root-cause state | +|---|---| +| a goal, scope, persona or constraint that was never captured | `REQUIREMENT_ANALYSIS` | +| a convention, benchmark or platform claim that turned out wrong | `RESEARCH` | +| a change in *what* to build or its priority (direction, not execution) | `PRODUCT_REVIEW` — the Direction Approval Gate re-opens | +| a missing journey, an unserved user need, an uncovered edge | `UX_PLANNING` | +| a missing or unreachable state, a wrong branch/guard, a boundary that no longer holds | `FLOW_GENERATION` | +| a component, token, layout, motion, contrast or density spec | `UI_PLANNING` | +| the spec was right and the build does not match it | `PROTOTYPE` | +| the defect passed a green check | `SELF_AUDIT` **and** the owning artifact state — the check gap is its own item (R2) | + +**A repeat is evidence of misrouting.** If an item is dispatched to a state, +returns, and comes back again, the root cause is upstream of where it was sent. +Four consecutive change requests dispatched palette work to `UI_PLANNING` and +`PROTOTYPE`; the machine ran to `HALT_BLOCKED` before it was established that the +real fault was **wrong-document adoption at `UI_PLANNING`** — a design-system spec +belonging to a different project. Three cycles were spent on colour values because +the routing was never re-examined after the first return. + +### R4 — Dispatch a bounded scope + +Each dispatched item carries what changes **and what must not**. + +One request asked for a palette change on a single screen. `PROTOTYPE` +over-applied it and remixed the brand mark. The next cycle's first item was +*"restore the original mark"*, and the log gained a permanent constraint: **the +logo is an untouchable brand asset** (composition, colours, order, wordmark). A +revision that does more than the item asked manufactures the next revision item. + +Constraints discovered this way are recorded in the log, not carried in memory. + +### R5 — Supersession is part of the change; aged items are re-verified + +When a revision replaces a component, the replaced one **leaves in the same +change**. One round superseded a whole card component outright; two later rebuilds +deleted entire selector families and string-key sets. A leftover selector or +string is an un-specced element and fails `skills/06` and `skills/07` V-rules +exactly as an addition does. The log carries a **Superseded** table. + +The mirror of this: **an open change item ages against a moving prototype.** By +the time one carried defect was fixed, three of its six reported selectors *no +longer existed* — two intervening rounds had deleted them. Re-verify every carried +item against current bytes before dispatch; an item that no longer applies is +closed as `superseded`, with the round that removed it named, not silently +dropped. + +### R6 — Re-validate through `SELF_AUDIT`, or record a waiver + +§3 gives this state exactly one return edge: `REVISION` → rebuild complete → +`SELF_AUDIT` → `USER_REVIEW`. + +The extraction run shortcut that edge repeatedly, verifying rebuilds with targeted +headless assertions and returning straight to the gate. Result: **five of seven +approved flows arrived at their approval gate past their audit of record**, one of +them four rounds past. That became tracked debt, waived at the gate, and when the +audit was finally re-run against the frozen bytes (PASS 386/386) it immediately +found **three more real defects**, including the font root cause above. + +Targeted verification is evidence *inside* the loop. It is not the audit. +Returning to `USER_REVIEW` without a current audit is a **waiver**, and per +`skills/09` G6 a waiver names its rider debt item. + +An in-review delta that claims to be audit-neutral must **say what makes it +neutral**. One did: asset wiring only, no layout or copy change, screenshots +re-verified — so the existing audit still held. That is a defensible claim because +it was written down. + +### R7 — Count the loop, out loud, every cycle + +`L_REVISION` is per feature. `L_AUDIT_FIX` is per audit round and, on breach, +**escalates into `L_REVISION` accounting** — it does not reset it. + +On the extraction run the counters stopped being written after the second round +of the first flow. That flow was recorded at `L_REVISION=2` and then delivered +three more rounds — **five against a ceiling of three**. The inner audit-fix loop +ran ×4 on one flow and ×8 on another against a ceiling of three, neither +escalating. **A ceiling nobody counts is not a ceiling.** Every log entry carries +`iteration` and `loop: L_REVISION (n/3)` in frontmatter. Ceilings live in +`toolkit.config.json` → `loops`. + +What counts as one cycle: **one round = one prototype rebuild, however many +sub-lettered asks it folds** — a round folding nine sub-asks is still one round. +This is the same rule as `skills/09` G7 — bump the count in the log, the progress snapshot and the flow +row in the same edit, or they drift. + +The ceiling resets **only** by explicit user authorization, recorded in the log. +One flow did this correctly: 3/3 consumed → `HALT_BLOCKED` with an escalation +summary → the user authorized a fresh bounded window → reset to 1/3, written +down. + +### R8 — A conflict goes to the Mini-Gate, never into the bytes + +Two classes reach this state: + +1. **Two change requests contradicting each other.** +2. **A change request contradicting an already-ratified decision** — the class the + extraction run actually hit, three times: + - a supplied spec asked for a control whose ownership had already been ruled + onto a different screen, twice; + - a supplied mockup's navigation contradicted a logged user decision, across + six files; + - a spec asked for two colours the token allowlist does not contain. + +Neither class is a build decision. When the user is not at the gate: ship the +**reversible** reading, open an `o-` item naming both sides and what each would +cost, and put it to the gate. Never silently pick a side inside the prototype — +`skills/07` states the same rule from the build side. + +The same applies to canon conflicts between two *approved* deliverables. On the +extraction run two approved flows shipped contradictory values for the same +user-visible fact, and it was deliberately **not** patched: silently editing one +approved deliverable to hide a disagreement with another is worse than the +disagreement, and it moves frozen bytes without a ruling. + +## Output — `artifacts/revision-log.md` + +Append per iteration; newest first. The frontmatter is what `FINAL_OUTPUT` §8 +rule 6 reads. + +```markdown +--- +artifact: revision-log +version: rev-<feature>-NN +produced_by: revision +reads_versions: + review-record.md: review-<feature>-NN + audit-report.md: audit-<feature>-NN +iteration: <n> +loop: L_REVISION (<n>/3) # L_AUDIT_FIX (<n>/3) if this is an audit-fix cycle +feature: <feature/flow id> +--- + +# Iteration <n> — <source: audit verdict `fail` | user `request-changes`>, <date> + +## Change set + +| # | Change item | Raised by | Class + sweep | Root cause | Target state | Status | +|---|---|---|---|---|---|---| +| CR1 | *"<verbatim ask or finding>"* | user-review \| self-audit | <class — N instances found across M files, or "single instance"> | <where the fault was introduced> | `UI_PLANNING` → `PROTOTYPE` | resolved \| deferred \| superseded | + +_Every item carries a target state and a status (V1). Nothing exits `open` (V2)._ + +## Conflicts — Conflict Mini-Gate + +| # | Conflict | Side A | Side B | Ships as | Gate outcome | +|---|---|---|---|---|---| +| o-x1 | <the contradiction, both sides stated> | <new ask> | <ratified decision + its id> | <the reversible reading> | pending user ruling \| ruled <date> | + +_No conflict is resolved inside the prototype (R8, V6)._ + +## Dependency order (upstream → downstream) + +1. `<STATE>` → <artifact vNN> (<items>) +2. `PROTOTYPE` → proto-<feature>-NN (<items>) +3. `SELF_AUDIT` → audit-<feature>-NN +4. `USER_REVIEW` → gate reverts to `pending` (stale-approval rule §5) + +## Superseded by this revision + +| Component / rule / string | Superseded by | Removed from | +|---|---|---| + +## Constraints recorded + +<discovered scope limits — untouchable assets, decisions that must not move.> + +## Deliberately not changed + +| Item | Why not | +|---|---| + +_Recorded so silence is not mistaken for oversight (V2)._ + +## Impact analysis + +| Artifact | Effect | +|---|---| +| requirements.md | unchanged | +| ui-plan.md | **ui-<feature>-NN** (supersedes -NN) | +| prototype | **proto-<feature>-NN** | +| audit-report.md | **audit-<feature>-NN** | + +## Re-validation + +| Check | Result | +|---|---| +| Re-audit on the rebuilt bytes | `audit-<feature>-NN` PASS N / N — or **WAIVED**, rider: debt #<n> | +| Rendering-class assertions | N / N | +| Class sweeps from R2 | <class: N instances, all fixed> | + +## Validation self-check + +- **V1** ✅ / ❌ — every item has a target state and a status. +- **V2** ✅ / ❌ — no item left `open`. +- **V3** ✅ / ❌ — iteration incremented; `L_REVISION <n>/3`, `L_AUDIT_FIX <n>/3`. +- **V4** ✅ / ❌ — every item names its class and sweep result. +- **V5** ✅ / ❌ — superseded components removed; carried items re-verified against current bytes. +- **V6** ✅ / ❌ — conflicts recorded with both sides; none resolved in the bytes. +- **V7** ✅ / ❌ — return edge passes through `SELF_AUDIT`, or a waiver names its rider. + +**Exit:** dispatch to <states> → `SELF_AUDIT` → `USER_REVIEW`. +``` + +## Validation rules (machine-checkable on output) + +- **V1:** Every change item has a target state and a status. +- **V2:** No change item left `open` at state exit — `resolved`, `superseded`, or + explicitly `deferred` **with a reason**. +- **V3:** Iteration number incremented; loop ceilings not exceeded **and the + counters actually written** (`L_REVISION`, `L_AUDIT_FIX`). +- **V4** *(hardened, per R2)*: Every item names its **defect class** and + the sweep result. An item recorded as a single instance asserts that the class + was checked, not that it was not looked for. +- **V5** *(hardened, per R5)*: Components replaced by this revision are + removed, not left as dead style/strings; and every carried item was re-verified + against current bytes before dispatch. +- **V6** *(hardened, per R8)*: Every conflict is recorded with **both + sides** and its Mini-Gate outcome. No conflict is resolved inside the + prototype. +- **V7** *(hardened, per R6)*: The return edge to `USER_REVIEW` passes + through `SELF_AUDIT` on the rebuilt bytes. If it does not, a waiver is recorded + and it names its rider debt item. + +## Exit conditions + +All change items `resolved` or `deferred`; downstream states re-validated. + +## Failure recovery + +- **`L_REVISION` ceiling (3 full cycles)** → `HALT_BLOCKED` with an **escalation + summary** of unresolved items — never a 4th unbounded cycle. Fully persisted + and resumable; not a failure terminal. +- **`L_AUDIT_FIX` ceiling (3)** → escalate into `L_REVISION` accounting. It does + not reset the outer loop. +- **Conflicting change requests** → **Conflict Mini-Gate** before dispatch (R8). +- **A change request with no content** (`request-changes` with nothing specified) + → the loop cannot be entered on an empty change set. Halt, write the escalation + summary, and state the resume path — the concrete change list plus an explicit + authorization that resets the window. +- **Missing input artifact** (no `audit-report.md` or `review-record.md`) → + back-transition to the state that owed it. A revision with no source is not a + revision. + +## Approval gate + +**None required to start a revision.** Re-approval happens when the loop returns +to `USER_REVIEW`, and per §5 the Primary User Approval Gate **reverts to +`pending`** the moment approved artifacts change — that reversion is a +consequence of this state's own dispatch, so record it in the dependency order. + +**Conflict Mini-Gate** — location `REVISION`; blocks *dispatching conflicting +change requests*; grantor: the **user**; on deny: the user resolves the conflict, +then dispatch proceeds. + +## Recorded failure modes + +### A. Routing + +| Case | What happened | Rule | +|---|---|---| +| **Class never swept** | A defect reported as six selectors in one file; three were patched and the class was dropped. The root cause — an opt-in token layer — resurfaced 24 days later at **138 instances across 7 flows**, with a second defect behind it. | R2, V4 | +| **Root cause in the machine, not the artifact** | Invented colours passed a green audit because the audit checked DS *presence*, never non-DS *absence*. The fix had to be two dispatches: a new permanent `SELF_AUDIT` rule, and the values. | R2, R3 | +| **Three cycles on a misrouted root cause** | Four change requests dispatched palette work downstream; the real fault was **wrong-document adoption at `UI_PLANNING`** — a design-system spec belonging to a different project. The machine reached `HALT_BLOCKED` before the routing was re-examined. | R3 | +| **Over-application creating the next item** | A one-screen palette change was applied so broadly that the brand mark was remixed; the next item was *"restore the mark"*. | R4 | +| **Items aged against a moving prototype** | Three of one carried defect's six reported selectors no longer existed when it was actioned — two intervening rounds had deleted them. | R5 | + +### B. Loop accounting + +| Case | What happened | Rule | +|---|---|---| +| **The counter stopped being written** | `L_REVISION` was last recorded at **2** for a flow that then delivered three more rounds — **five against a ceiling of three**, with no breach ever detected. | R7, V3 | +| **`L_AUDIT_FIX` breached twice with no escalation** | ×4 on one flow and ×8 on another against a ceiling of 3. Neither escalated into `L_REVISION` accounting. | R7 | +| **The return edge skipped** | Rebuilds went back to the gate on targeted assertions instead of a re-run audit; **five of seven approved flows** were past their audit of record at approval. The eventual re-run found three more real defects. | R6, V7 | +| **The log covered one flow out of eleven** | The revision log only ever recorded the first flow, so §8 rule 6 (*no `open` change items*) was unevaluable for the other ten until a consolidated entry backfilled it at closure. | V1, V2 | + +### C. What worked + +- **The `HALT_BLOCKED` escalation.** A fourth `request-changes` arrived with no + change items, against a consumed 3/3 ceiling. The machine halted, wrote the + escalation summary (everything resolved, the unresolved set, the standing + disclosed minors) and a three-step resume path, then reset the window **only** + on explicit user authorization — recorded in the log. That is exactly what §7's + *"can never silently continue"* is asking for. +- **The `Deliberately NOT changed` table.** Four items — a canon conflict between + two approved deliverables, a tap-target gap, a progress readout that was visible + at approval, and six superseded ACs — each with the reason it was left. V2 is + satisfiable by silence; this table is what makes it *honest*. diff --git a/design-toolkit/skills/11-final-output/README.md b/design-toolkit/skills/11-final-output/README.md new file mode 100644 index 0000000..be419ec --- /dev/null +++ b/design-toolkit/skills/11-final-output/README.md @@ -0,0 +1,27 @@ +# Skill: `final-output` — STATE 11 + +**Freeze, package, write the handoff, and close the machine.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 11 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-11--final_output) + +[← skills/](../README.md) · prev ← [12 flow-visualization](../12-flow-visualization/README.md) or [09 user-review](../09-user-review/README.md) · next → `DONE` + +| Field | Value | +|---|---| +| Machine state | `FINAL_OUTPUT` | +| Reads | approved artifact set, `review-record` | +| Writes | `deliverable-<feature>/`, terminal `machine_state` | +| Depends on | user-review (`approve`) | +| Approval gate | None additional — gated by the granted Primary Approval | +| Retry ceiling | 2 (packaging faults) | +| Next states | `DONE` / `REVISION` (completeness regression) | + +## The rule that matters most here + +A freeze is a **hash, not a copy**. All six completion rules are checked explicitly, each with a boolean *and a one-line reason*. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/11-final-output/SKILL.md b/design-toolkit/skills/11-final-output/SKILL.md new file mode 100644 index 0000000..e1afaea --- /dev/null +++ b/design-toolkit/skills/11-final-output/SKILL.md @@ -0,0 +1,400 @@ +--- +name: final-output +description: >- + State 11 of the AI Product Design Agent workflow, and the only state that can + close the machine. Freezes the approved artifact versions, packages the + deliverable, writes the handoff document, checks every §8 Completion Rule + explicitly, and emits the terminal record. Use when the Primary User Approval + Gate has been granted and the work is to be shipped: the approval is verified + as current *and* as naming the exact bytes being frozen, the freeze is a hash + rather than a copy, acceptance criteria are checked against the traceability + matrix rather than from memory, the audit of record is confirmed to have run + on the bytes being frozen, every waived rule ships with an id and a rider debt + item, known limitations ship inside the deliverable at full strength, and the + machine record is closed in the same edit as the freeze. Reads the approved + artifact set and review-record.md; writes deliverable/ and the terminal + machine_state. Depends on user-review (`approve`). Approval gate: none + additional — this state is gated by the already-granted Primary User Approval. +--- + +# Final Output (STATE 11) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 11, +> §3 Transitions, §4 Conditions, §5 Approval Gates, §8 Completion Rules. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests delivery. It communicates +> only through the artifact store (`artifacts/`), never directly with other +> skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | approved `artifacts/prototype/`, `artifacts/review-record*.md` (`approve`), `artifacts/traceability*.md`, `artifacts/audit-report*.md`, `artifacts/revision-log*.md`, the full artifact set, `machine_state` | +| Writes | `artifacts/deliverable-<feature>/` (frozen prototype + `handoff-<feature>.md`) and the terminal `state/machine_state.yaml` record | +| Depends on | `user-review` with outcome `approve` — this state has no other entry | +| Approval gate | **None additional.** Gated by the already-granted **Primary User Approval Gate**. Re-approval only if content changes post-approval (§5 stale-approval rule) | +| Retry ceiling | §6 default **2** for faults *inside* packaging (an unwritten hash, a missing handoff). A completeness regression is **not** a retry — it is the declared `REVISION` edge | +| Next states | `DONE` (terminal success) / `REVISION` (completeness regression caught at the last gate) | + +## Purpose + +Produce the finalized, packaged deliverable and close the workflow. + +This is the last place a wrong claim can be caught, and the only place the +machine is allowed to say it is finished. Every other state produces an artifact +someone downstream will check; this one produces the **record that the checking +is over**. §8's six Completion Rules are its real specification — V1–V4 below are +how they are enforced at the moment of freezing. + +The run this toolkit was extracted from reached `FINAL_OUTPUT` **eleven times** +and closed the machine once. At that closure: two of the four validation rules had +been waived at the previous gate, `machine_state.yaml` sat two days stale while +the machine reported itself shipping, one flow's handoff did not exist, and §8 +rule 6 was unevaluable for ten of eleven flows. All four were caught at the final +sweep. The method below is what would have caught them **at the gate they were +made at**. + +## Processing steps + +1. **Verify** `USER_REVIEW` outcome is `approve` and current. +2. **Freeze** artifact versions; assemble the final package. +3. **Generate** handoff documentation (decisions, traceability, known limitations). +4. **Run** the final completeness check against acceptance criteria. +5. **Emit** the terminal record and close the machine. + +## Packaging method (hardened) + +Steps 1–5 say *what*. This section is the contract for *how*, and each rule was +written by something that went wrong at, or just past, delivery. Codes are cited +from handoffs and gate records — index in +[`docs/method-rules.md`](../../docs/method-rules.md). + +### P1 — The approval must be current **and** must name its bytes + +§5: *an approval is scoped to the artifact versions it saw.* That makes +`reads_versions` in `review-record.md` a load-bearing field of **this** state — +it is what §8 rule 2 is checked against. A record that cannot name its bytes +cannot be shipped from, and the fix is to go and get the naming, not to infer it. + +The most recent gate record on the extraction run **dropped `reads_versions` +entirely**, naming its prototype and audit only in a body table. An earlier record +is the correct shape: frontmatter naming the version reviewed, with the outcome +*scoped to* a later version — saying out loud that the bytes moved between review +and approval. + +Before freezing, classify any post-approval delta per `skills/09` **G4**: + +- **bug-fix-only** → a one-line scope confirm, and the byte-level evidence bar + (identical hex inventory, diff confined to named regions, the pre-fix file + reconstructed from the inverse delta hashing back to the approved sha). +- **feature delta** → a ruling. The gate is `pending`; there is nothing to + package yet. + +One prototype rebuilt an entire screen as a new surface **after** its approval. +It was a feature delta, and the deliverable still held the previous version — the +deliverable and the prototype disagreed about what had shipped for a full day, +until it was ratified and re-frozen. + +### P2 — A freeze is a hash, not a copy + +Copying a folder records nothing. Every frozen file is listed with its **sha256** +in the handoff and in `machine_state.freeze`, and those hashes are what the next +gate, the next audit and the next revision compare against. + +One deliverable per **approval gate**, named for what that gate approved — a +single gate covering five flows produces one deliverable folder and one handoff, +and every flow's row in `machine_state.flows` names the folder it landed in. + +**A screen that is not in a frozen deliverable is not delivered.** One flow was +built direct: its screens read `designed` in the registry and were never frozen +into any deliverable, because the flow had no upstream pipeline documents to +freeze. `designed` and `delivered` are different claims and the snapshot must not +blur them. + +### P3 — V2 is checked against the traceability matrix, never from memory + +The final completeness check is mechanical: every acceptance criterion id in +`requirements-<feature>.md` appears in `traceability-<feature>.md` with a status +in `{met, waived, superseded}`. Anything else — including absence — is `unmet`. + +One batch shipped 21 screens with **condensed pipeline artifacts and no +traceability matrix at all**, so V2 was not failed — it was *unevaluable*, and the +gate recorded a V3 waiver instead. When the matrix was finally built at closure it +covered 26 requirements / 90 ACs: **83 met, 6 superseded by later ratified +revisions, 1 met-with-advisory, 0 unmet**. + +`superseded` is a legitimate status and it **names the ratified revision that +superseded it**. An AC quietly dropped because a later round replaced its screen +is indistinguishable from an AC that was never built, unless the supersession is +written down. + +### P4 — The audit of record must have run on the bytes being frozen + +§8 rule 4 is not "an audit passed", it is `C_AUDIT_PASS` **on the final prototype +version**. Compare the sha the audit ran against with the sha being frozen. If +they differ, this is not a packaging problem — it is a `SELF_AUDIT` problem, and +per `skills/10` **R6** the return edge exists precisely for it. + +**Five of seven approved flows arrived at their gate past their audit of +record** — one three rounds past, one four. Each round had been verified by +targeted headless assertions and screenshots, which is evidence *inside* the loop +and is not the audit. That became tracked debt, waived at the gate. When the audit +was finally re-run against the frozen bytes it returned **PASS 386/386, stable +over two runs, 37 screens driven** — and found **three more real defects** on the +way there, including the whole app rendering one script on a stack with no face +for it: 138 instances across 7 flows, the root cause of a defect first reported 24 +days earlier. + +A green audit on superseded bytes is not a green audit. + +### P5 — §8 rule 6 needs a revision log that exists + +*No `open` change items in the latest `revision-log.md`* is checked by reading the +log's frontmatter and change-set table. A missing log does not read as "no open +items" — it reads as **no evidence**, and it fails the rule. + +The revision log only ever covered the first flow. The 13 revision rounds across +the other flows lived in status prose, not in the artifact this state reads, so +rule 6 was unevaluable for ten of eleven flows until a consolidated closure entry +backfilled it. + +### P6 — A waiver is a legitimate exit; silence is not + +§8 rule 3 permits an acceptance criterion to be `waived` **with record**. The +record is the whole of the permission. A waiver ships only when all three hold: + +1. The **user grants it** — the machine cannot waive its own rules. +2. It is written into the deliverable's **Known limitations** *and* opened as a + numbered debt item, with an id both sides can cite. +3. It states **what would close it**. + +Audit currency and a missing traceability matrix were both waived this way at one +gate, each riding a numbered debt item, and **both were closed the next day** — +one by a re-run audit that found three more defects, the other by a backfill that +reported 0 unmet. That is the waiver working exactly as designed. + +**The waiver was never the problem. The silence would have been.** + +### P7 — Close the machine record in the same edit as the freeze + +§8 rule 1 is a claim about the record, and a record written later is a +reconstruction. `current_state`, `last_transition`, `approvals`, the per-flow +terminal rows, the freeze hashes and an explicit `completion_check` for C1–C6 are +written **in the same edit** that freezes the bytes. + +`machine_state.yaml` sat **two days and two approval rounds stale** — still +reading `current_state: USER_REVIEW`, gate `pending`, while seven flows had been +approved and the machine was reporting itself as shipping. Nothing detected it, +because nothing was reading the file the completion rule is defined over. + +Each of the six rules gets a boolean **and a one-line reason**. `C3: true` with +no reason is the same silence P6 forbids. + +### P8 — Known limitations ship inside the deliverable, at full strength + +Every limitation the pipeline discovered goes into the handoff in the terms it +was discovered in — not softened, not summarised into a reassurance. The +receiving team will otherwise discover it in build, at a much higher price. + +The extraction run shipped 20 parked open decisions, a provisional design-system +palette, a tap-target gap against its own acceptance criterion, and a design file +whose 48 frames were **render-backed images, not component-decomposed vector +designs**. The last one is the model: stated plainly, with why (decomposition +wants the real DS export first) rather than omitted because it reads badly. + +Two shapes from that run are the reference: + +- A handoff's *Known limitations* table — four opens, each with what ships instead + and what would close it, including a genuine canon conflict between two + *approved* deliverables that was **deliberately not patched** (silently editing + one to hide a disagreement with another is worse than the disagreement). +- A requirements document's acceptance section, which records the five + qualifications on that acceptance rather than presenting it as clean. + +Client-facing packages (requirements document, developer handoff, data-screen +contract, design file) are **additive** to `deliverable/`, not substitutes for it, +and each cites the frozen versions it was generated from. + +## Output — `artifacts/deliverable-<feature>/` + +``` +artifacts/deliverable-<feature>/ +├── prototype/ # the frozen bytes, hashed +└── handoff-<feature>.md # V4 +``` + +Plus the terminal record in `state/machine_state.yaml` (P7). + +### `handoff-<feature>.md` + +````markdown +# <Project> — <Feature> · Design Handoff + +_Feature: **<feature>** · State: **DONE** (Primary User Approval granted <date>, +<n>th pass, scoped to `proto-<feature>-NN`) · Date: <date>_ + +> <standing caveats that apply to everything below — provisional tokens, +> unreviewed copy, simulated content.> + +## What this delivers + +| Screen | ID | Built | +|---|---|---| +| <name> | S-XXX-NN | <what actually ships on it> | + +**States:** <every state a screen can be driven into> + +## Requirement source + +<the brief, the registry rows, or the promises already frozen into other flows. +Say which — a flow built with no user brief is a different object from one built +to a spec, and the receiving team needs to know.> + +## Key decisions + +- **D-XN — <decision>.** <the ruling and why it went that way.> + +## Pipeline artifacts + +requirements-<feature> (<n>R / <n> ACs) → research-<feature> → +product-review-<feature> (**proceed**, D-X0..) → ux-plan-<feature> → +flows-<feature> → ui-plan-<feature> → traceability-<feature> → +**prototype `proto-<feature>-NN`** → audit-report-<feature> (**PASS n/n**) → +review-record-<feature>. + +**Figma:** <file id + page, or PENDING with what blocks it.> + +## Acceptance criteria + +- **audit-<feature>-NN PASS — n / n ACs** (run against the frozen bytes, P4). +- <headless assertion count, run stability, console sweep, hex conformance, + screenshot count.> +- Superseded ACs: <n>, each naming the ratified revision that replaced it (P3). + +## Freeze + +| File | sha256 | +|---|---| +| `prototype/<file>.html` | `<sha256>` | + +## Review packet + +``` +prototype/run-local.sh → play.html #<feature> +``` + +Hooks: <every deep-link hook, from traceability — this is what makes the +deliverable re-drivable after the loop closes.> + +## Waivers + +| Rule | Waived because | Rider | What closes it | +|---|---|---|---| +| V2 / §8 rule 4 | <reason> | debt #<n> | <the specific action> | + +_A waived rule ships only with a grantor, a rider and a closing condition (P6)._ + +## Known limitations + +| ID | Limitation | +|---|---| +| **o-xN** | <the open, what ships instead, what would close it — at full strength (P8).> | + +## §8 Completion Rules + +| # | Rule | Holds | Evidence | +|---|---|---|---| +| 1 | `current_state` = `DONE` | ✅ | machine_state written this edit | +| 2 | approval scoped to the final frozen versions | ✅ | review-<feature>-NN `reads_versions` + freeze sha | +| 3 | `C_ALL_CRITERIA_MET` | ✅ | traceability-<feature>: n met / n waived / n superseded / **0 unmet** | +| 4 | `C_AUDIT_PASS` on the final version | ✅ | audit-<feature>-NN, run on the frozen sha | +| 5 | deliverable + handoff + traceability + decision log exist | ✅ | this folder | +| 6 | no `open` items in the latest `revision-log.md` | ✅ | rev-<feature>-NN | +```` + +## Validation rules (machine-checkable on output) + +- **V1:** `USER_REVIEW` = `approve` and not superseded by a later change request. +- **V2:** 100% of acceptance criteria marked `met` (or user-waived, recorded). +- **V3:** All artifacts version-frozen and referenced. +- **V4:** Handoff doc present. +- **V5** *(hardened, per P1 + P2)*: Every frozen file is listed with its + **sha256**, and that hash is the version the approval names. An approval that + cannot name its bytes fails V1 rather than being inferred from. +- **V6** *(hardened, per P4)*: The audit of record ran on **the bytes + being frozen**. If it did not, a waiver is recorded and names its rider debt. +- **V7** *(hardened, per P5 + P7)*: All six §8 Completion Rules are + checked **explicitly and individually**, each with a boolean and a one-line + reason, in the deliverable and in `machine_state.completion_check`. No rule is + asserted by silence, and rule 6 is failed — not passed — when the revision log + for the feature does not exist. +- **V8** *(hardened, per P6 + P8)*: Every waived rule and every known + limitation carries an **id**, a **rider debt item** and **what would close it**. + +## Exit conditions + +All validation passes → machine enters terminal `DONE`. + +## Failure recovery + +- **V2 fails at the last moment** → do **not** ship. Route to `REVISION` with the + **specific unmet criteria**, never a general "completeness failed". No silent + shipping of incomplete work. +- **Approval is stale** (bytes moved after approval, P1) → the gate reverts to + `pending` per §5. That is a `USER_REVIEW` return, not a freeze — classify the + delta first (`skills/09` G4) so the return asks for the right thing: a scope + confirm or a ruling. +- **Audit of record predates the frozen bytes** (P4) → back to `SELF_AUDIT` on + the frozen bytes, or ship on an explicit user-granted waiver with a rider. +- **Missing input artifact** — no traceability matrix, no revision log, no + handoff — → back-transition to the state that owed it. A completeness check run + over a missing artifact is not a pass. +- **Packaging fault** (unwritten hash, unwritten handoff) → retry inside this + state, ceiling 2, then `HALT_BLOCKED` with a diagnostic. + +## Approval gate + +**None additional.** Final packaging is gated by the already-granted **Primary +User Approval Gate**; re-approval is required only if content changes +post-approval — which is P1's whole subject, and is checked here rather than +assumed. + +## Recorded failure modes + +### A. Freeze integrity + +| Case | What happened | Rule | +|---|---|---| +| **The deliverable and the prototype disagreed** | A screen was rebuilt as a new surface after approval; the deliverable still held the previous version. A **feature** delta over approved bytes, not a bug fix. | P1, V1 | +| **The gate record dropped the field this state reads** | One record omitted `reads_versions` — the exact field §8 rule 2 is checked against — naming its prototype and audit only in a body table. | P1, V5 | +| **`designed` mistaken for `delivered`** | One flow was built direct; its screens read `designed` in the registry and were never frozen into any deliverable, because there were no upstream artifacts to freeze. | P2 | +| **External references age too** | Handoffs cited design-file pages that had stopped matching the prototype — one flow's frames were four revision rounds out of sync. A frozen deliverable does not freeze the things it links to. | P2, P8 | + +### B. Completion-rule accounting + +| Case | What happened | Rule | +|---|---|---| +| **The machine record was stale while the machine reported shipping** | `machine_state.yaml` read `current_state: USER_REVIEW`, gate `pending`, for **two days and two approval rounds** after seven flows were approved. §8 rule 1 is defined over that file and nothing was reading it. | P7, V7 | +| **A rule was unevaluable, not failed** | One batch shipped with no traceability matrix, so V2 could not be checked at all; the gate recorded a V3 waiver instead. The backfill later found 26 requirements / 90 ACs — 83 met, 6 superseded, 1 met-with-advisory, **0 unmet**. | P3, V2 | +| **A green audit on superseded bytes** | Five of seven approved flows reached their gate past their audit of record, verified by targeted assertions instead. The eventual re-run on the frozen bytes — PASS 386/386 — found **three more real defects**, including one at 138 instances across 7 flows. | P4, V6 | +| **Rule 6 unevaluable for ten of eleven flows** | The revision log only ever covered one flow; 13 revision rounds lived in prose, not in the artifact this state reads, until a closure entry backfilled it. | P5, V7 | +| **A handoff that did not exist at close** | One flow's handoff — V4's entire subject — was written during the final sweep, after the flow had been recorded as delivered. | P7, V4 | + +### C. What worked + +- **The two waivers.** Audit currency and a missing traceability matrix were + waived at the gate *with numbered riders and closing conditions*, and both were + closed the next day — one by an audit re-run that found three more real defects, + the other by a backfill reporting 0 unmet. A waiver is a working part of the + machine; an unrecorded shortcut is not. +- **The last handoff written.** Freeze hashes, a review packet listing every + deep-link hook, and a *Known limitations* table carrying a real canon conflict + between two approved deliverables — **deliberately unpatched** rather than + silently reconciled. It is a deliverable that can still be driven, and audited, + after the loop closed. +- **A requirements document's acceptance section**, which records the five + qualifications on the acceptance instead of presenting it as clean. +- **The final audit as the model for P4** — re-run against the final frozen bytes + of every flow, 386/386, stable over two consecutive runs, 37 screens driven, + every check rendering-class per `skills/08` M1. diff --git a/design-toolkit/skills/12-flow-visualization/README.md b/design-toolkit/skills/12-flow-visualization/README.md new file mode 100644 index 0000000..23538e7 --- /dev/null +++ b/design-toolkit/skills/12-flow-visualization/README.md @@ -0,0 +1,27 @@ +# Skill: `flow-visualization` — STATE 12 + +**Generate the navigation map so the design is ready for development.** + +Full contract: [SKILL.md](SKILL.md) · spec: [docs/workflow.md](../../docs/workflow.md) §STATE 12 · guide: [WORKFLOW_GUIDE.md](../../WORKFLOW_GUIDE.md#state-12--flow_visualization) + +[← skills/](../README.md) · prev ← [09 user-review](../09-user-review/README.md) (`approve`) · next → [11 final-output](../11-final-output/README.md) · numbered 12 by authoring order, runs before 11 + +| Field | Value | +|---|---| +| Machine state | `FLOW_VISUALIZATION` | +| Reads | `flows`, screen registry, lanes, `prototype/`, the design file | +| Writes | `navgraph.json`, `navmap-report.md`, `flow-visualization-<scope>.md`, design-file pages | +| Depends on | flow-generation **and** user-review (`approve`) | +| Approval gate | **Developer Handoff Gate** — blocks `FINAL_OUTPUT` | +| Retry ceiling | 3 | +| Next states | `FINAL_OUTPUT` / `FLOW_GENERATION` / `REVISION` / self-loop | + +## The rule that matters most here + +**Derive the graph; never draw it.** The gate passes on the report and the tool's exit code, not on the picture. Skipped entirely when `handoff_required` is false. + +## Contract rule + +This skill communicates **only** through the artifact store (`artifacts/`), never +directly with another skill. It runs when the orchestrator — or an explicit user +task — requests this state, and not otherwise. diff --git a/design-toolkit/skills/12-flow-visualization/SKILL.md b/design-toolkit/skills/12-flow-visualization/SKILL.md new file mode 100644 index 0000000..56cb051 --- /dev/null +++ b/design-toolkit/skills/12-flow-visualization/SKILL.md @@ -0,0 +1,631 @@ +--- +name: flow-visualization +description: >- + STATE 12 of the AI Product Design Agent workflow (module DWF-05), on the edge + between the Primary User Approval Gate and delivery. Turns the approved flow + graphs into a navigation map a developer can build from without asking a + designer a question: journey sections, styled connectors carrying trigger and + guard, decision nodes, screen metadata, swimlanes, a cross-feature map, a + navigation heatmap, deep-link addressing, per-screen state machines, developer + annotations, and a PM/QA overview page. Use when flows are ratified and the + work is heading for developer handoff: every connector is derived from the + screen registry rather than drawn, the derivation is a tool with an exit code, + a boundary is treated as a dated claim rather than a permanent property, and + the gate passes on the report rather than on the picture. Reads flows-*.md, + reference/screen-registry.csv, reference/nav-lanes.json, the prototype and the + design file; writes navgraph.json, navmap-report.md, the design-file flow pages + and flow-visualization-<scope>.md. Depends on flow-generation and on user-review + (`approve`). Approval gate: Developer Handoff Gate. +--- + +# Flow Visualization & Navigation Mapping (STATE 12 · DWF-05) + +> Source of truth: [../../docs/workflow.md](../../docs/workflow.md) §STATE 12, +> §3 Transitions, §5 Approval Gates. +> This skill is one state of the workflow state machine. It runs only when the +> orchestrator (or an explicit user task) requests navigation mapping. It +> communicates only through the artifact store (`artifacts/`), never directly +> with other skills. + +## Contract + +| Field | Value | +|-------|-------| +| Reads | `artifacts/flows-<feature>.md`, `reference/screen-registry.csv`, `reference/nav-lanes.json`, `artifacts/prototype/` (hook scan), `artifacts/traceability-<feature>.md`, `artifacts/deliverable-*/handoff-*.md`, the Figma design file, `machine_state` | +| Writes | `artifacts/navgraph.json`, `artifacts/navmap-report.md`, `artifacts/flow-visualization-<scope>.md`, and the Figma flow sections / cross-feature map / overview page | +| Depends on | `flow-generation` (graphs exist) **and** `user-review` = `approve` for every flow being mapped | +| Approval gate | **Developer Handoff Gate** — blocks `FINAL_OUTPUT` | +| Retry ceiling | §6 flow-heavy **3**, then back-transition to `FLOW_GENERATION` | +| Next states | `FINAL_OUTPUT` (normal) / `FLOW_GENERATION` (a route exists in the registry that no flow graph ratified) / `REVISION` (registry and prototype disagree about a route) / `FLOW_VISUALIZATION` (self-loop, sync or connector fix) | + +## Purpose + +Generate the navigation visualization inside the design file so the design is +**ready for development**, not merely visually complete. + +## Why this is not STATE 05 + +`FLOW_GENERATION` and this state both hold graphs, and they are not the same +object. + +| | STATE 05 · `FLOW_GENERATION` | STATE 12 · `FLOW_VISUALIZATION` | +|---|---|---| +| Node | a *state the user is in* | a *frame in the design file* | +| Question | is this flow correct? | can a developer build from this file without asking? | +| Screens | deliberately screen-free | screen-only — a node with no frame is a finding | +| Truth | ratified by review of the prototype | derived from the registry, checked by exit code | +| When | before any screen is named | after the approval gate, before the freeze | + +STATE 05 rules what the flow *is*. STATE 12 proves the design file *says so*, to +somebody who was not in the room. A flow graph can be perfect and still hand off +badly: the extraction run shipped eight per-flow map pages and still could not +answer *"which screens does the home screen reach, and which reach it"* without a +person reading eleven documents. + +## Processing steps + +1. **Derive** the navigation graph from the registry — `tools/navgraph.mjs`. +2. **Reconcile** the derived graph against `flows-<feature>.md`. Registry routes + with no ratified edge, and ratified edges with no registry route, are + findings, not merge candidates. +3. **Lay out** one Figma Section per journey, screens left → right on an 8pt + grid, branches vertical, merges reconnecting cleanly. +4. **Draw** connectors, styled by class, each carrying its trigger / action / + condition label. +5. **Place** decision nodes at every branch, with mutually exhaustive labels + carried over from the flow graph's guards. +6. **Stamp** screen metadata on every frame (W6). +7. **Generate** the enterprise layers E1–E7 (swimlanes, cross-feature map, + heatmap, deep links, state machines, developer annotations, overview page). +8. **Validate**, then present the report — not the picture — at the gate. + +## Method (hardened) + +Each rule below was written by something that went wrong at, or just past, +handoff. Codes are cited from reports and gate records — index in +[`docs/method-rules.md`](../../docs/method-rules.md). + +### W1 — Derive the graph; never draw it + +Every connector in the file traces to a cell in `reference/screen-registry.csv`. +The derivation is a tool with an exit code, so "the map matches the registry" is +a check rather than a claim: + +```bash +node tools/navgraph.mjs --fail-on major +# → artifacts/navgraph.json (nodes, edges, heat, states, deep links, findings) +# → artifacts/navmap-report.md +``` + +Paths come from `toolkit.config.json`; the registry it reads is +`paths.registry`. A connector drawn by hand is an assertion nobody can re-check. If a route +belongs in the map and not in the registry, the fix is to fix the registry. + +### W2 — One Section per journey, never per feature + +``` +FLOW-001 • New User Onboarding +FLOW-002 • Existing User Login +FLOW-003 • Password Reset +``` + +Section name format is `FLOW-XXX • Journey Name`. Journeys do not share a +Section, because the reason to open a Section is to follow one path end to end. +A Section named after a *feature* silently becomes a bucket, and a bucket +answers no question. + +### W3 — Layout is a contract, not taste + +- Screens ordered left → right in traversal order. +- 240–320 px between frames, uniform within a Section. +- Everything on the 8pt grid. +- Branches descend vertically from their decision node; merge points reconnect + to the main line rather than crossing it. + +A reviewer scanning right is reading the happy path. That only holds if the +ordering is enforced, so it is checked (**V4**), not assumed. + +### W4 — Arrow style carries meaning, and the legend ships in the file + +| Class | Style | Means | +|---|---|---| +| Primary navigation | solid 2 px, arrow head | the ratified happy route | +| Alternative path | dashed | a ruled non-default branch | +| Error / failure | red dashed | a route taken only on failure | +| Modal / sheet | curved | overlay, not a view push | +| External link | dotted | leaves the app | + +Without a **Flow Legend** frame in the same file, four line styles are +decoration. The legend is part of the output, not documentation about it. + +### W5 — In a Design file, a connector is a vector and it does not reflow + +Figma's connector object — the one that re-routes when a frame moves — is +**FigJam**. In a Design file the arrows are vectors: move a frame and the arrow +stays where it was, still looking correct. This is the single most dangerous +property of the artifact, because a stale arrow is indistinguishable from a +fresh one. + +Two consequences, both binding: + +1. Connectors are **regenerated wholesale** from `navgraph.json` on every sync. + They are never hand-patched — a hand patch is a fact that exists in exactly + one place. +2. When a journey is large or volatile enough that regeneration is expensive, + the auto-routing copy belongs in **FigJam** (`generate_diagram` / + `get_figjam`), and the Design file carries the frames. `navgraph.json` stays + the authority for both. + +`use_figma` writes require the `/figma-use` skill loaded first — mandatory, +every call, no exceptions. + +### W6 — Every frame carries its own metadata + +Seven fields, on the frame, in the file: + +``` +Screen ID · Screen Name · Route · Feature · Flow · Version · Status +``` + +`Version` is the prototype version the frame was rendered from +(`proto-<feature>-NN`), and `Status` is the registry status. A frame that cannot +say which bytes it depicts cannot be checked against them — which is exactly how +Study's Figma page drifted four revision rounds behind the prototype (R5–R8) and +nothing detected it. + +### W7 — Sync is triggered by a hash, not by memory + +Whenever a screen is renamed, moved, deleted, added, or re-routed, the map is +out of date. The trigger is mechanical: `navgraph.json` records the registry it +was derived from, and a re-derivation whose edge set differs from the committed +one **is** the sync signal. On that signal: + +1. Regenerate connectors (W5). +2. Re-lay the Section (W3). +3. Refresh decision nodes and flow labels. +4. Re-run E2/E3 (a new edge changes the cross-feature map and the heatmap). +5. Record the change in the requirements document and in + `flow-visualization-<scope>.md`. + +"We updated the Figma" is not a sync record. A diff of the edge set is. + +### W8 — A boundary is a dated claim + +`skills/05`'s `⟂` boundary node is correct **when written** and silently wrong +the moment the owning flow ships. The extraction run carried **eleven live boundary +call sites** routing to a placeholder long after every destination flow existed. + +In this state the equivalent is a cross-feature port drawn as external when the +target is now a real frame in the same file. So: every boundary port is +re-derived from `navgraph.json` at each sync, and the boundary table records the +**date** its status was last checked. A status with no date is not a status. + +### W9 — Render-backed frames are fine here; state variants are not optional + +This file's 48 screens are **render-backed images, not component-decomposed +vector designs**. For navigation mapping that is acceptable — connectors attach +to the frame, and the frame is the node. + +What is *not* optional is that every state a screen can be in exists as its own +frame. E5 attaches a state machine to a screen; if `loading` and `error` live +only inside the prototype, the state diagram has nothing to point at, and the +developer reads the map as "this screen has one state". The delivery pass built +93 frames for 48 screens precisely because of this. + +### W10 — The gate passes on the report, not on the picture + +A screenshot of a flow map is persuasive and proves nothing. What is presented +at the Developer Handoff Gate is `navmap-report.md` plus the tool's exit code, +with every finding either cleared or carrying a named waiver and a rider debt +item (`skills/11` P6). A picture that looks right over a report that says +`2 blocking` is the exact failure this state exists to prevent. + +## Enterprise extensions + +E1–E7 are not optional garnish; each one answers a question the base map cannot, +and each is **derived** by the same tool that derives the edges. An extension +that has to be maintained by hand is an extension that goes stale — so where the +data does not exist, the extension reports its absence instead of inventing it. + +### E1 — Swimlane layout (Customer / Admin / System / API) + +Within a Section, frames are banded into horizontal lanes by the actor that +drives the transition. Lane order is fixed top → bottom so lanes read the same +across every Section: `customer · admin · system · api`. + +**Derivation.** The registry has no actor column, so lanes come from the lane file +named in `toolkit.config.json` → `paths.lanes`. Unassigned screens are reported +(`N8`), never guessed into a lane, because a wrong lane reads as a ruling about +who owns a screen. + +**A single-actor product is the honest degenerate case**: every screen in +`customer`, with `admin`, `system` and `api` declared and empty. That was the +extraction run's output, and it is *why* system/API attribution is carried at the +**edge** level by E6's annotation columns rather than by node lanes — an +auto-advance splash and a payment webhook are properties of the *transition*, not +of the screen. + +For multi-actor products the same file scales: assign back-office screens to +`admin`, timer/daemon-driven surfaces to `system`, and service-mediated steps to +`api`, and the layout engine bands them without any other change. + +### E2 — Cross-feature flow mapping + +The base map shows a journey. This shows the **seams** — every edge whose source +and target live in different features, e.g. `Login → Transfer → Notification`. + +**Derivation.** `navgraph.json.crossFlow`, computed from the registry. + +**Expect roughly half the edge set to cross a feature boundary** — it was 46 of +100 on the extraction run. That is precisely the half no single flow document +owns. Output: + +- a dedicated **`🗺 Cross-Feature Map`** page, features as nodes, edge weight = + number of screen-level routes; +- a **boundary port** on each Section for every inbound and outbound cross-flow + edge, naming the owning flow and the date its status was checked (W8). + +This is the machine version of `skills/05`'s `⟂` node, and it is what would have +caught the stale-boundary class: a port whose target is now a real frame in the +same file cannot keep rendering as external, because the derivation says +otherwise. + +### E3 — Navigation heatmap + +**Derivation.** In-degree per screen, plus the count of *distinct source +features* — a screen reached six times from one feature is a busy screen; a +screen reached from six features is a hub, and hubs are where regressions land. + +Rendered as a three-step fill ramp on the frame chrome, **with the raw numbers +printed** — a colour with no number is a vibe. The handoff consumes the top rows +directly: hub screens get the caching, the back-stack rules and the regression +budget. + +On the extraction run the top screen carried **11 inbound routes from 8 +features** — that is the screen whose back behaviour must be specified before +build starts, not during it. + +**Heat is measured, never assigned.** A designer's sense of which screen is +important is exactly the input this extension exists to replace. + +### E4 — Deep-link visualization + +Each frame carries the URL that drives it, so QA and developers can reach the +state directly instead of walking the flow. + +**Derivation.** The tool scans the prototype pages for the query hooks each page +actually reads (`?view`, `?state`, `?lang`, …) — the hooks are read **out of the +implementation**, not out of a doc that claims them. + +On the extraction run **8 of 11 flow pages exposed hooks; three exposed none at +all** (`N9`, major ×3). Those three could not be re-driven into a state after +handoff — a reviewer could not *open* an error screen, only navigate to it. + +That is worth stating precisely, because the tracked debt item recorded "no +deep-link hook" for **one** flow. The mechanical scan found the same defect in +**three**. A finding scoped to one flow was read as scoped to one flow, and it was +not — `skills/05`'s *scoped claim read as global* rule, running in the other +direction. + +### E5 — State-transition diagrams + +Per screen: `Empty → Loading → Success → Error`, drawn as a compact state +machine attached under the frame, with the trigger on each edge. + +**Derivation.** The registry's `states` column, one machine per screen. + +**Expect the vocabulary to be the blocker, not the diagram.** On the extraction +run the registry carried **59 distinct free-text state labels across 48 screens, +49 of them outside any canonical set** — including *three* spellings of "empty for +a new user". Each was locally sensible. The set was not a state machine, and no +tool could tell whether two labels meant one concept or two. + +So the rule is ordering, not effort: + +1. Normalize to a **closed vocabulary**, with the specific case as a + **qualifier**, not a new label: `canon` or `canon{qualifier}` — + `error{invalid-number}`, `empty{new-user}`. +2. Then generate. Generating first freezes 59 private vocabularies into a + deliverable. + +The vocabulary, the qualifier rule and the full original → normalized mapping live +in the vocabulary file (`toolkit.config.json` → `paths.vocabulary`; start from +[`templates/state-vocabulary.md`](../../templates/state-vocabulary.md)). +`tools/navgraph.mjs` enforces both the term set (`N11-state-vocab`) and the syntax +(`N11-state-syntax`), and `tools/stategraph.mjs` enforces the term set again +(`S1-vocab`) over the authored edge set. Both import `CANON_STATES` from +`tools/config.mjs` — **one definition, two enforcers**. Adding a term is an edit +to that constant plus the justification in the vocabulary file; there is no third +copy to keep in step. On the extraction run 58 qualified labels resolved to 12 +canon terms with **0 findings** — and the originals were preserved in the mapping +table, so the rewrite lost nothing. + +Two rules the normalization pass produced: + +- **The qualifier is kept, never dropped.** `error{wrong-otp}` and + `error{unchecked-terms}` are different screens' different recoveries. +- **Adding a canon term costs a justification**, written into the vocabulary + file. `confirm` and `filtered` earned their place because folding them into + `happy` would have been a false statement about the screen; a term only one + screen would ever use is a qualifier, not a canon term. + +What building the layer added to the rule: + +- **The node set is derived; the edge set is authored *with evidence*.** The + registry owns which states exist (`tools/stategraph.mjs` fails the run if a + machine adds or drops one). Transitions cannot be derived — they live in the + prototype's control flow — so each one carries `evidence` as `file:line` and a + `kind` (`user` · `system` · `entry` · `data`), and the tool checks the cited + line exists in the frozen bytes. An unevidenced arrow is the failure mode this + whole layer exists to avoid: it looks like a spec and is a guess. +- **Proving a hook is *read* is not proving the state is *shown*.** `stategraph` + greps the page for the query parameter; `tools/stateprobe.mjs` then drives every + hook URL headlessly and asserts the active view is **computed-visible** with ink + on it — the M1 rule, and the corollary that *a hook which seeds state is not a + hook that shows it*. The first probe run on the extraction set reported 37 + failures and **every one was the harness** (an offline webfont and a favicon 404 + counted as app errors, a node threshold tuned to a busy screen failing + correctly-sparse empty states, and two hooks naming a fixture id the catalogue + does not contain). Corrected, not waived. +- **Three node flags carry what a plain diagram would hide.** `entry-only` (real, + but only ever built on arrival — no in-screen path), `terminal` (no outbound + change by design; the way out is leaving the screen), and **`NOT IMPLEMENTED`**, + drawn dashed: the registry declares the state and the frozen bytes never render + it. Seven of 118 states were dashed on the extraction run. Dropping them would + have made the deliverable agree with itself by deleting the disagreement. +- **The probe measures id drift instead of asserting it.** Reading the prototype's + own screen-id readout at every hook produced 11 observations where the registry + id and the printed id differ — confirming one known conflict and opening the same + class on a second flow. Recorded in `id_conflicts`, **not reconciled**: + renumbering is a registry decision. + +Validation codes: `S0` machine exists per registry screen · `S1` node set == +registry (and still vocabulary-legal) · `S2` initial declared and real · `S3` +endpoints + trigger kinds · `S4` evidence resolves to a real line · `S5` hook +parameter actually read by the page · `S6` reachable from initial or explicitly +`entry_only` · `S7` outbound or `terminal` · `S8` registry ↔ prototype id drift · +`S9` declared-but-unbuilt, with the absence evidenced. + +### E6 — Developer annotations + +Each connector carries a four-field annotation, and each frame a two-field one. +This is the layer that turns a diagram into a spec. + +| Field | On | Values | Source | +|---|---|---|---| +| `nav` | edge | `push` · `replace` · `modal` · `sheet` · `tab` · `back` · `deep-link` | flows-*.md transitions table | +| `anim` | edge | the named motion preset, or `none` | ui-plan motion spec | +| `api` | edge | the call this transition triggers, or `none` (simulated) | data-screen contract | +| `guard` | edge | the flow graph's guard expression | flows-*.md | +| `auth` | frame | `guest-ok` · `auth-required` · `premium` | registry states + flows | +| `perm` | frame | OS permission the screen needs, or `none` | ux-plan | + +Two rules make this survivable: + +- **`UNKNOWN` is a legal value and a guessed value is not.** An `api` field filled + with a plausible endpoint is worse than an empty one, because the developer will + build it. Every `UNKNOWN` is counted in the report. +- **Annotations cite, they do not restate.** The field carries the value *and* the + artifact it came from. On the extraction run every `api` value read + `none (simulated)` — no network call existed anywhere in the prototypes — and + saying so in the field was the single most useful thing this layer did for the + receiving team. + +Built with `tools/annotate.mjs` against the annotation spec named in +`toolkit.config.json` → `paths.edgeAnnotations`. What building it added to the +rule: + +- **Split the six fields by who owns the answer, exactly as E5 splits nodes from + edges.** `api` and `anim` are **derived** — a re-run sweep, and the CSS the page + declares. `nav` and `guard` are **authored with evidence**, because they live in + the control flow: each carries the `file:line` of the call site and the tool + resolves it against the frozen bytes. A citation that lands past the end of the + file, or on a line that has since gone blank, is a finding — that is what makes + the annotation survive the next revision instead of quietly aging. +- **Re-run the sweep; never trust the recorded claim.** The `api` column is only + worth reading because the tool greps for `fetch` / `XMLHttpRequest` / `WebSocket` / + `sendBeacon` / `EventSource` itself on every run and fails (`E11`) if the column + says `none (simulated)` and the sweep disagrees. The most valuable column in this + layer was the one where every value is identical — and it is only valuable + because it is *measured* each time. +- **`nav` is the field that finds the missing routes.** Assigning a kind forces + the question *which control does this?*, which `navgraph` never asks — it derives + routes from the registry. On the extraction run that surfaced **four registry + routes with no call site at all** (three previously unrecorded) and one new + class: **`hook_only`**, a route that exists as a URL hook with no in-screen + control behind it. Navigable by QA, unreachable by a user. "Navigable" and + "implemented" are different claims and the annotation has to distinguish them. +- **State the value the bytes carry, not the value the plan asked for.** The plan + specified a 280 ms view push; the build shipped 260 ms, and four files shipped + none at all. Annotating the plan's number would have produced a document that is + wrong in exactly the way a developer cannot detect. +- **Hash the drawing against the spec.** After the page is drawn, read its rows + back out of the design file and hash them against `annotations.json` — identical + signature, or the page and the artifact disagree. A picture that has drifted from + its source is worse than no picture, and W5 already says connectors do not + reflow, so prove the page says what the artifact says rather than asserting it. + +Validation codes: `E0`/`E1` missing / blank field · `E2` uncited value · `E3` +value outside the closed set · `E4` citation does not resolve in the frozen bytes · +`E5`/`E6`/`E7` duplicate / uncovered / orphan edge · `E8` `nav` = `UNKNOWN` (no call +site) · `E9`/`E10` frame coverage · `E11` the `api` claim vs the re-run sweep · +`E12` an `anim` naming an animation the cited file does not declare · `E13` +`hook_only`. + +### E7 — Auto-generated flow overview page (for PM and QA) + +One page, generated last, that nobody has to assemble by hand: + +``` +🗺 Flow Overview +├── Counts <n> screens · <n> edges · <n> flows · <n> cross-feature +├── Coverage every registry screen → its Section + frame link +├── Heatmap the E3 table, sorted +├── QA paths every canon entry path, each with its deep-link URL +├── Findings navmap-report.md's table, verbatim, with waivers +├── Legend the W4 arrow classes + the E1 lane order +└── Provenance registry sha · navgraph run · prototype versions · date +``` + +The **Provenance** block is what makes the page checkable a month later, and it +is the block that gets dropped first. It ships or the page does not. + +## Output + +### `artifacts/navgraph.json` + `artifacts/navmap-report.md` + +Machine-derived, regenerated, never hand-edited. `navgraph.json` is the +authority every other output in this state is generated from. + +### `artifacts/flow-visualization-<scope>.md` — the state's record + +```markdown +--- +artifact: flow-visualization +version: navmap-<scope>-NN +produced_by: flow-visualization +reads_versions: { screen-registry.csv: <sha>, flows-<feature>.md: <version>, prototype: proto-<feature>-NN } +scope: <the flows this map covers — stated as scope, per skills/05> +figma: { file: <key>, pages: [<name · node-id>] } +gate: { developer_handoff: granted|pending|waived, date: <date> } +--- + +# Navigation Map — <scope> + +## Sections built +| Section | Journey | Screens | Edges | Figma node | + +## Findings at gate +| Severity | Code | Subject | Status (cleared / waived + rider debt) | + +## Boundary status (W8) +| Port | Owning flow | Status | Checked on | + +## Sync record (W7) +| Date | Trigger | Edge-set delta | Actions taken | + +## Extensions +| Ext | Status | Evidence | +| E1 swimlanes | <n>/<n> screens laned | reference/nav-lanes.json | +| E3 heatmap | derived | navgraph.json.heat | +| E4 deep links | <n>/<n> flows addressable | navgraph.json.deepLinks | +``` + +### In Figma + +Per journey: a `FLOW-XXX • Journey Name` Section holding frames, connectors, +decision nodes, branch/decision labels, entry and exit markers, lane bands, and +the per-frame metadata + annotation blocks. Plus, once per file: the +`🗺 Cross-Feature Map` page, the `🗺 Flow Overview` page, and the Flow Legend. + +## Validation rules (machine-checkable on output) + +- **V1:** Every Screen Contract / registry entry has a frame in a Section. +- **V2:** Every navigation path in `navgraph.json` exists as a connector. +- **V3:** No orphan screens — `navgraph.json` reports **0** `N2-orphan`. +- **V4:** No broken connectors: every connector's endpoints are frames that + still exist, at the coordinates the connector was generated against (W5). +- **V5:** All branches terminate — every decision node's branch set is exhaustive + and every branch reaches a frame or a justified terminal. +- **V6:** Entry and exit screens identified; every Section has ≥1 marked entry. +- **V7:** Section names follow `FLOW-XXX • Journey Name`. +- **V8:** Connector directions match `navgraph.json` edge direction. +- **V9** *(E1)*: Every screen has a lane, or the unlaned set is named in the + report — `N8` is cleared or waived, never absent. +- **V10** *(E4)*: Every flow's deep-link addressability is reported; a flow with + no hooks is a **major** finding carrying a rider debt item, not an omission. +- **V11** *(E5)*: State labels are drawn from the closed vocabulary, with case + detail as qualifiers. `N11` cleared or waived with the count stated. +- **V12** *(E6)*: No annotation field is blank. `UNKNOWN` is legal and counted; + blank is a failure. +- **V13** *(W7)*: The committed `navgraph.json` re-derives byte-identically from + the current registry. A drifted derivation means the map is stale, whatever + the picture looks like. + +## Exit conditions + +`node tools/navgraph.mjs --fail-on major` exits **0**, or every remaining +finding carries a granted waiver with a rider debt item and a closing condition +(`skills/11` P6). V1–V13 pass. The Developer Handoff Gate is `granted`. + +## Failure recovery + +- **V2 / V3 failure — a route in the registry that no flow graph ratified** → + back-transition to `FLOW_GENERATION`. Do not draw the edge here; drawing it + ratifies it, and this state has no authority to rule a branch (`skills/05`, + *branch invented, not ruled*). +- **Registry and prototype disagree about a route** — on the extraction run the + registry claimed an edge that did not exist, because the destination screen + re-implemented the control inline instead of routing → `REVISION`. Exactly one of + the two is wrong and this state cannot tell which; routing it to the state that + owns the fault is `skills/10` R2. +- **V4 failure — connectors detached after frames moved** → self-loop: + regenerate from `navgraph.json`. Never nudge an arrow back into place. +- **V13 failure — derivation drifted** → re-run the derivation, re-lay the + affected Sections, and record the edge-set delta in the sync table. A sync + with no recorded delta did not happen. +- **Retry ceiling 3.** A persistent unreachable screen means the registry and + the ratified flows disagree at the root → `FLOW_GENERATION`. + +## Approval gate — Developer Handoff Gate + +Developer Handoff cannot pass until: + +- navigation visualization complete (V1, V2, V6, V7), +- screen contract synchronized (V13), +- connectors validated (V4, V8), +- Sections organized (V3, V5), +- no broken navigation (tool exit 0 at `--fail-on major`, or waivers), +- flow diagrams up to date (W7 sync record present and dated). + +Grantor: the user. On deny: the denial names the failing rule, and the state +self-loops or back-transitions per Failure recovery. The gate record goes into +`flow-visualization-<scope>.md` frontmatter, and — per `skills/11` P1 — it names +the **versions it saw**: the registry sha, the `navgraph.json` run and the +prototype versions the frames were rendered from. A gate that cannot name its +bytes is the defect one real gate record shipped — it dropped `reads_versions` +entirely, which is the exact field delivery is checked against. + +Status on pass: **READY FOR DEVELOPMENT**, scoped to the flows named in `scope` +— and scoped in the claim itself, never as a bare "handoff ready". + +## What a first derivation finds + +The first run is not tool noise. On the extraction run it opened at **2 blocking · +8 major · 52 advisory**, and *every finding was a real defect in the registry*. +The classes, because they recur: + +| Code | What it means | Why it happens | +|---|---|---| +| `N2b-inbound-only-declared` | A screen is reachable in the product and undrawable from the registry — the only evidence of the route lives on the receiving screen. | `entry_from` was filled in and `navigates_to` was not. | +| `N3-asymmetric` | A forward edge is missing. | Verify against the flow graphs **and** the prototype before touching the cell. | +| `N3b-backedge` | `entry_from` names a screen that no longer routes here, or omits one that now does. | `entry_from` is written when a screen is designed and never updated when a *later* flow starts routing to it. Advisory — the forward edge is authoritative and the map draws correctly — and worth fixing anyway, because `entry_from` is what a developer reads to answer "who can send me here". | +| `N10-unparsed` | A registry cell carries prose where an id belongs. | Hand-editing. | +| `N11-state-vocab` | State labels outside the closed set. | See E5 — normalize before you generate. | + +Two numbers worth carrying: + +- Repairing **three cells** added **six edges** and **five cross-feature routes**. + A navigation model can be 6% wrong and look complete. +- The most instructive single finding was a boundary that had been **promoted to a + real handoff in the code and never written back to the registry** (W8) — caught + by derivation, invisible to reading. + +Before E6 ran, **43 of 106 edges carried a label** and the other 63 were +unlabelled routes. Assigning the E6 values closed that gap *and* found four more +routes with no call site at all, plus one addressable only by URL. + +## Recorded failure modes + +| Class | What happened | Rule | +|---|---|---| +| **The picture outlived the truth** | One flow's design-file page went out of sync at revision 5 and stayed wrong through revision 8 — four separate rebuilds missing — while the page still looked complete. Another carried a frame showing four rows against a screen rebuilt to six categories. | W6, W7, V13 | +| **A boundary that stopped being a boundary** | Eleven live call sites still routed to the placeholder screen after every destination flow had shipped. Correct when written, silently wrong once the owning flow existed. | W8, E2 | +| **A route with no edge behind it** | The registry claimed an edge the code does not implement — the destination screen re-implements the control inline instead. Two implementations of one control, synced by convention. **The E6 pass found three more of the same class**, none of them visible to `navgraph`, because deriving a route from the registry never asks whether a control exists behind it. | V2, E6 `E8`, Failure recovery | +| **Navigable read as implemented** | One route is real and reachable — **by URL**. The control the registry names it after is a non-interactive element. A QA hook proves a state can be *shown*, never that a user can *get there*. | E6 `hook_only` / `E13` | +| **A destination that differs from the one named** | A route carried no query string, so it landed on a feature's home screen rather than the checkout step the registry names. The edge exists; only the endpoint is wrong, which is the hardest version to see. | E6 `nav` evidence, `skills/08` M6 | +| **A gap recorded at the wrong scope** | A debt item recorded "no deep-link hook" for one flow. The mechanical scan found the same defect in two more — the finding was true and its scope was not. | E4, `skills/05` scoped-claim rule | +| **A vocabulary that cannot compose** | 59 distinct state labels across 48 screens, including three spellings of "empty for a new user". Each was locally sensible; the set was not a state machine. Normalized into 12 canon terms + qualifiers, originals preserved in the mapping table. | E5, V11 | +| **Frames that depict unnamed bytes** | Design-file frames carrying no prototype version cannot be checked against the prototype, so drift is undetectable rather than merely undetected. | W6 | +| **Green picture, red report** | This state's whole risk: a flow map that renders beautifully over a derivation reporting broken routes. The gate reads the report. | W10, V13 | + diff --git a/design-toolkit/skills/README.md b/design-toolkit/skills/README.md new file mode 100644 index 0000000..eada7e0 --- /dev/null +++ b/design-toolkit/skills/README.md @@ -0,0 +1,92 @@ +# `skills/` — the twelve states + +[← Repository root](../README.md) · [Workflow Guide](../WORKFLOW_GUIDE.md) · [Spec](../docs/workflow.md) + +--- + +## Purpose + +One folder per workflow state. Each is a self-contained Claude Skill with a typed contract: what it reads, what it writes, what must be true to leave it, and what to do when it fails. + +**The states are here. The state machine is not.** No skill holds `machine_state`, evaluates a guard, or decides what happens next — that is the orchestrator's job. A skill runs when asked, does one state's work, writes its artifact, and stops. + +| Folder | State | Writes | Gate | +|---|---|---|---| +| [`01-requirement-analysis/`](01-requirement-analysis/) | `REQUIREMENT_ANALYSIS` | `requirements-<f>.md` | Clarification (conditional) | +| [`02-research/`](02-research/) | `RESEARCH` | `research-<f>.md` | — | +| [`03-product-review/`](03-product-review/) | `PRODUCT_REVIEW` | `product-review-<f>.md` | **Direction Approval** | +| [`04-ux-planning/`](04-ux-planning/) | `UX_PLANNING` | `ux-plan-<f>.md` | — | +| [`05-flow-generation/`](05-flow-generation/) | `FLOW_GENERATION` | `flows-<f>.md` | — | +| [`06-ui-planning/`](06-ui-planning/) | `UI_PLANNING` | `ui-plan-<f>.md` | Extension Note (informational) | +| [`07-prototype/`](07-prototype/) | `PROTOTYPE` | `prototype/`, `traceability-<f>.md` | — | +| [`08-self-audit/`](08-self-audit/) | `SELF_AUDIT` | `audit-report-<f>.md` | — | +| [`09-user-review/`](09-user-review/) | `USER_REVIEW` | `review-record-<f>.md` | **Primary User Approval** | +| [`10-revision/`](10-revision/) | `REVISION` | `revision-log-<f>.md` | Conflict Mini-Gate (conditional) | +| [`11-final-output/`](11-final-output/) | `FINAL_OUTPUT` | `deliverable-<f>/` | — (gated by 09) | +| [`12-flow-visualization/`](12-flow-visualization/) | `FLOW_VISUALIZATION` | `navgraph.json`, `navmap-report.md`, design-file pages | **Developer Handoff** | + +> **12 is numbered by authoring order, not machine order.** It sits on the `USER_REVIEW (approve) → FINAL_OUTPUT` edge and runs before 11 whenever `handoff_required` is true. + +## Inputs + +Whatever each state's contract names — always files in [`artifacts/`](../artifacts/) or [`reference/`](../reference/), plus `machine_state`, plus the raw brief in STATE 01's case. + +**Skills never read each other's internals.** If STATE 06 needs something STATE 05 knows, it reads `flows-<feature>.md`. + +## Outputs + +One artifact per state, written to [`artifacts/`](../artifacts/) with conforming frontmatter. Plus, for STATE 06, the colour allowlist written into `toolkit.config.json`; and for STATE 11, the terminal record in `state/machine_state.yaml`. + +## The anatomy of a `SKILL.md` + +Every one has the same eight sections, and the second half is the part that matters: + +| Section | What it carries | +|---|---| +| **Contract** | Reads · Writes · Depends on · Approval gate · Retry ceiling · Next states | +| **Purpose** | The single decision this state owns | +| **Processing steps** | *What* to do | +| **Hardened method** | *How* — each rule written by a defect that shipped, with a citable code | +| **Output** | The exact frontmatter and body shape | +| **Validation rules** | `V1`–`V4` from the spec; `V5+` project-hardened | +| **Exit conditions / Failure recovery** | What must hold to leave, and where to go when it does not | +| **Recorded failure modes** | The evidence for the hardened method | + +The `README.md` beside it is a one-screen orientation: the contract table, plus **the single rule that matters most in that state**. + +## Examples + +**Invoking a state.** One state per request. The agent does that state and stops. + +``` +Run STATE 04 ux-planning from skills/04-ux-planning/SKILL.md. +Read artifacts/requirements-checkout.md, artifacts/research-checkout.md +and artifacts/product-review-checkout.md. +Write artifacts/ux-plan-checkout.md. +``` + +**Checking a contract before you run.** Open the skill's `README.md` — the contract table tells you whether the inputs exist and whether an upstream gate is still `pending`. + +**Citing a rule from a log:** + +```markdown +Deep-link hooks added for all 6 states per B2; recorded in traceability-checkout.md. +Superseded card component stripped per B7 — 4 selectors, 2 string keys, 1 handler. +``` + +## Best practices + +- **Run one state per request.** The stopping places are the point. A skill that runs ahead removes the checkpoint where you would have caught the problem cheaply. +- **Do not work around a missing input.** A missing artifact is a **back-transition** to the state that owed it. "The spec does not say" is a routing signal, not a licence to invent. +- **Read the Recorded failure modes before running a state for the first time.** They are short, and each one is a defect you would otherwise reproduce. +- **A `V5+` rule is not optional.** `V1`–`V4` are the specification; `V5+` exist because a defect passed `V1`–`V4`. +- **Never edit a skill to make a rule easier.** If a rule is wrong, the fix is a new recorded defect and a spec change — in that order. +- **Keep skills stateless.** If a skill starts wanting to remember something between runs, that is machine state, and it belongs in `state/machine_state.yaml`. +- **The description frontmatter matters.** It is what makes a skill discoverable by an agent. Keep it specific about *when* to use the skill, not just what it does. + +## Related + +- [`WORKFLOW_GUIDE.md`](../WORKFLOW_GUIDE.md) — all twelve states with durations, common mistakes and cross-references +- [`docs/workflow.md`](../docs/workflow.md) — the specification each skill implements +- [`docs/method-rules.md`](../docs/method-rules.md) — every hardened rule, indexed by code +- [`ARTIFACT_FLOW.md`](../ARTIFACT_FLOW.md) — what each skill's output is and who consumes it diff --git a/design-toolkit/state/.gitkeep b/design-toolkit/state/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/design-toolkit/state/README.md b/design-toolkit/state/README.md new file mode 100644 index 0000000..2350057 --- /dev/null +++ b/design-toolkit/state/README.md @@ -0,0 +1,134 @@ +# `state/` — the machine's own record + +[← Repository root](../README.md) · [Architecture § 3](../ARCHITECTURE.md#3--the-orchestrator) · [Contracts spec](../docs/artifact-contracts.md) + +--- + +## Purpose + +One file: `machine_state.yaml`. + +**It is not an artifact.** Artifacts are produced by states and consumed by later states; this is the machine's record of itself — where it is, what has been approved, how many loop iterations have been spent, and what has been frozen. + +Completion rule 1 is defined over this file. So is the live-reload behaviour of the review server. + +## Inputs + +Seeded from [`templates/machine_state.yaml`](../templates/machine_state.yaml): + +```bash +cp templates/machine_state.yaml state/machine_state.yaml +``` + +Written by the **orchestrator** — a person, an agent, or (roadmap phase 2) a CLI. **No skill writes it.** Skills do one state's work and stop; deciding what happens next is not their job. + +Path is configured in [`toolkit.config.json`](../toolkit.config.json) → `paths.state`. + +## Outputs + +Read by: + +| Reader | What it needs | +|---|---| +| The orchestrator | `current_state`, `entry_count`, `loop_count`, `approvals`, `handoff_required` — to evaluate guards and fire transitions | +| Every skill | its own entry conditions, and whether an upstream gate is granted | +| [`templates/prototype/serve.py`](../templates/prototype/serve.py) | the top-level `current_state` — live reload is active **only** while it is `USER_REVIEW` | +| STATE 11 | `freeze`, `flows`, `completion_check` — the six completion rules are checked and closed here | + +## What it holds + +```yaml +machine_state: + project: <slug> + current_state: <STATE_NAME> + scope: <the feature this run is about> + gate: pending # pending | granted | denied + handoff_required: false # true → STATE 12 runs before FINAL_OUTPUT + blocked_reason: null + last_transition: { from, to, trigger, ts } + entry_count: {} # per-state visit counter → C_RETRY_OK + loop_count: # per-loop counter → C_LOOP_OK + L_CLARIFY: 0 + L_RESEARCH: 0 + L_UX_EDGE: 0 + L_REVISION: 0 + L_AUDIT_FIX: 0 + approvals: # per-gate; resumable + ClarificationGate: pending + DirectionApprovalGate: pending + PrimaryUserApprovalGate: pending + ConflictMiniGate: pending + DeveloperHandoffGate: pending + artifact_versions: {} + flows: [] # one row per feature; `deliverable` names the freeze folder + freeze: {} # sha256 per frozen file + completion_check: # a boolean AND a one-line reason, each + C1_state_is_done: false + C2_approval_scoped_to_final_frozen_versions: false + C3_all_criteria_met: false + C4_audit_pass_on_final_version: false + C5_deliverables_complete: false + C6_no_open_revision_items: false +``` + +## Examples + +**Recording a transition** — written in the same edit as the thing it records: + +```yaml + current_state: SELF_AUDIT + last_transition: + from: PROTOTYPE + to: SELF_AUDIT + trigger: "validation pass — traceability complete, smoke 8/8" + ts: "2026-08-07" + entry_count: { PROTOTYPE: 2, SELF_AUDIT: 1 } +``` + +**Granting a gate:** + +```yaml + approvals: + DirectionApprovalGate: granted # user, 2026-08-07, scoped to pr-checkout-01 +``` + +**Counting a loop, out loud:** + +```yaml + loop_count: + L_REVISION: 2 # of 3 — one more before HALT_BLOCKED + L_AUDIT_FIX: 1 +``` + +**Closing the machine** — in the same edit as the freeze: + +```yaml + current_state: DONE + freeze: + "deliverable-checkout/prototype/checkout.html": "a3f1…" + completion_check: + C1_state_is_done: true # written this edit + C2_approval_scoped_to_final_frozen_versions: true # review-checkout-02 reads_versions = proto-checkout-05 = frozen sha + C3_all_criteria_met: true # traceability: 22 met / 1 waived / 0 unmet + C4_audit_pass_on_final_version: true # audit-checkout-04 ran on proto-checkout-05 + C5_deliverables_complete: true # deliverable-checkout/ + handoff present + C6_no_open_revision_items: true # rev-checkout-03, 0 open +``` + +## Best practices + +- **Write it at decision time, in the same edit as the thing it records.** A record written later is a reconstruction. On the extraction run this file sat **two days and two approval rounds stale** — still reading `current_state: USER_REVIEW`, gate `pending` — while seven flows had been approved and the machine was reporting itself as shipping. Nothing detected it, because nothing was reading the file the completion rule is defined over. +- **Count the loop every cycle.** A ceiling nobody counts is not a ceiling. One flow was recorded at `L_REVISION = 2` and then delivered three more rounds — five against a ceiling of three. +- **A gate reverts to `pending` when approved artifacts change.** That reversion is a consequence of a revision's dispatch; record it in the revision log's dependency order and here. +- **Every completion rule gets a boolean *and* a one-line reason.** `C3: true` with no reason is silence, and silence is what the waiver rules forbid. +- **`designed` and `delivered` are different claims.** A flow's row names the deliverable folder its freeze landed in, or it was not delivered. +- **Do not let a parked flow's nested fields be mistaken for the machine's own.** `serve.py` reads the **first** two-space-indented `current_state`, which is `machine_state`'s own field; parked flows nest deeper. Keep that indentation. +- **Ceilings live in the config, counters live here.** `toolkit.config.json` → `loops` sets the limits; this file records the spend. +- **Commit it.** It is the record the completion rules are defined over. + +## Related + +- [`ARCHITECTURE.md § 3`](../ARCHITECTURE.md#3--the-orchestrator) — what the orchestrator does with this file +- [`WORKFLOW_GUIDE.md`](../WORKFLOW_GUIDE.md) — the entry conditions and ceilings per state +- [`templates/machine_state.yaml`](../templates/machine_state.yaml) — the annotated template +- [`docs/workflow.md`](../docs/workflow.md) §6 retry, §7 loops, §8 completion rules diff --git a/design-toolkit/templates/PROGRESS.md b/design-toolkit/templates/PROGRESS.md new file mode 100644 index 0000000..45b040b --- /dev/null +++ b/design-toolkit/templates/PROGRESS.md @@ -0,0 +1,56 @@ +# PROGRESS — <product> + +_Snapshot: <YYYY-MM-DD>_ + +> The priority tracker. Fixed format, updated **on every flow close and every debt +> change**. Detailed history lives elsewhere (a status log); this file answers one +> question: what is the state of the work right now, and what is the next thing. + +## Alignment snapshot + +| Metric | Value | +|---|---| +| Flows closed | 0 / 0 | +| Screens delivered | 0 / 0 | +| Gates granted | 0 | +| Open decisions | 0 | +| Debt items open | 0 | +| Machine state | `REQUIREMENT_ANALYSIS` | + +## Track A — blocked on someone else + +Items this workspace **cannot** act on alone. Each names who can unblock it and +what the one line would be. + +| # | Item | Blocked on | The one line needed | +|---|---|---|---| + +## Track B — actionable here + +Ordered. The top row is the next thing. + +| # | Item | State it belongs to | Why it is next | +|---|---|---|---| + +## Open decisions + +`o-<id>` items carried forward rather than defaulted. An unruled question that +reaches the prototype as an invented answer is how a placeholder gets frozen into +an approved deliverable. + +| ID | Question | What it blocks | Who can rule | Ships as (the reversible reading) | +|---|---|---|---|---| + +## Debt + +Every waiver rides on a numbered item here, and every item states what closes it. + +| # | Debt | Opened | Rides under | What closes it | Status | +|---|---|---|---|---|---| + +## Loop accounting + +A ceiling nobody counts is not a ceiling. + +| Feature | `L_REVISION` | `L_AUDIT_FIX` | Passes to approval | +|---|---|---|---| diff --git a/design-toolkit/templates/README.md b/design-toolkit/templates/README.md new file mode 100644 index 0000000..5671be0 --- /dev/null +++ b/design-toolkit/templates/README.md @@ -0,0 +1,105 @@ +# `templates/` — artifact shapes + +[← Repository root](../README.md) · [Artifact Flow](../ARTIFACT_FLOW.md) · [Contracts spec](../docs/artifact-contracts.md) + +--- + +## Purpose + +An empty, correctly-shaped starting point for every artifact the pipeline produces and every reference file it reads — with the load-bearing fields marked and the reason each one exists written next to it. + +A template is not decoration. Several of these files carry inline notes recording **why** a field exists, and those notes are the difference between filling a table in and understanding what the table is checked against. + +| Template | Shape for | Produced by | +|---|---|---| +| [`requirements.md`](requirements.md) | `requirements-<f>.md` | 01 | +| [`research.md`](research.md) | `research-<f>.md` | 02 | +| [`product-review.md`](product-review.md) | `product-review-<f>.md` | 03 | +| [`ux-plan.md`](ux-plan.md) | `ux-plan-<f>.md` | 04 | +| [`flows.md`](flows.md) | `flows-<f>.md` | 05 | +| [`ui-plan.md`](ui-plan.md) | `ui-plan-<f>.md` | 06 | +| [`traceability.md`](traceability.md) | `traceability-<f>.md` | 07 | +| [`audit-report.md`](audit-report.md) | `audit-report-<f>.md` | 08 | +| [`review-record.md`](review-record.md) | `review-record-<f>.md` | 09 | +| [`revision-log.md`](revision-log.md) | `revision-log-<f>.md` | 10 | +| [`handoff.md`](handoff.md) | `handoff-<f>.md` inside the deliverable | 11 | +| [`flow-visualization.md`](flow-visualization.md) | `flow-visualization-<scope>.md` | 12 | +| [`prototype/`](prototype/) | the Run Local review player | 07 copies it in | + +### Reference-file templates — copy these into `reference/` + +| Template | Becomes | Owner | +|---|---|---| +| [`screen-registry.csv`](screen-registry.csv) | `reference/screen-registry.csv` | the product — **the spine** | +| [`nav-lanes.json`](nav-lanes.json) | `reference/nav-lanes.json` | STATE 12 (E1) | +| [`state-vocabulary.md`](state-vocabulary.md) | `reference/state-vocabulary.md` | STATE 12 (E5) | +| [`state-machines.json`](state-machines.json) | `reference/state-machines.json` | STATE 12 (E5) | +| [`edge-annotations.json`](edge-annotations.json) | `reference/edge-annotations.json` | STATE 12 (E6) | +| [`audit-plan.json`](audit-plan.json) | `reference/audit-plan.json` | STATE 08, optional | + +### Machine and tracking templates + +| Template | Becomes | Notes | +|---|---|---| +| [`machine_state.yaml`](machine_state.yaml) | `state/machine_state.yaml` | Not an artifact — the machine's own record | +| [`PROGRESS.md`](PROGRESS.md) | wherever you track work | The priority tracker: alignment snapshot, blocked vs actionable, open decisions, debt, loop accounting | + +## Inputs + +None. Templates are static. + +## Outputs + +Nothing directly. They are copied — into `reference/`, into `state/`, or used as the shape a skill writes to in `artifacts/`. + +## Examples + +**Seeding a new product** (the four files every run needs): + +```bash +cp templates/screen-registry.csv reference/screen-registry.csv +cp templates/nav-lanes.json reference/nav-lanes.json +cp templates/state-vocabulary.md reference/state-vocabulary.md +cp templates/machine_state.yaml state/machine_state.yaml +``` + +**Standing up the review player** (STATE 07): + +```bash +cp templates/prototype/{run-local.sh,serve.py,play.html} artifacts/prototype/ +chmod +x artifacts/prototype/run-local.sh +``` + +**Adding the audit plan** when you need passes the state machines cannot express — locale × theme × reduced-motion: + +```bash +cp templates/audit-plan.json reference/audit-plan.json +``` + +**What a load-bearing field looks like in a template.** From `templates/machine_state.yaml`: + +```yaml + # Each gets a boolean AND a one-line reason. `true` with no reason is the + # silence skills/11 P6 forbids. + completion_check: + C1_state_is_done: false # <reason> +``` + +The comment is the rule. Deleting it does not remove the requirement. + +## Best practices + +- **Copy, then fill. Never edit a template in place.** The template is the shape for the *next* product too. +- **Keep the frontmatter.** A field a downstream rule is defined over belongs in frontmatter, not in a body table. `reads_versions` is the specific one a real gate record dropped, and it is exactly what delivery is checked against. +- **Keep the inline notes while you are learning the shape.** They record why a field exists. Strip them once the shape is second nature — not before. +- **Do not remove a table because it is empty.** An empty *Deliberately not changed* table and a missing one are different claims. The empty one says "nothing was left out"; the missing one says nothing at all. +- **Two separators in the registry, and they are not interchangeable.** `states` is comma-separated; `entry_from` and `navigates_to` are pipe-separated. Getting them backwards is reported as `N11-state-syntax`, not silently swallowed. +- **Register every new prototype page in `play.html`'s `FEATURES` array, in the same edit that creates the page.** A flow missing from the sidebar is a flow the user does not review. +- **Improving a template is a contribution.** Improving it by removing a required field is not. + +## Related + +- [`ARTIFACT_FLOW.md`](../ARTIFACT_FLOW.md) — what each artifact is, who consumes it, and which part is load-bearing +- [`docs/artifact-contracts.md`](../docs/artifact-contracts.md) — the naming scheme and the frontmatter contract +- [`templates/prototype/README.md`](prototype/README.md) — the Run Local review player +- [`reference/README.md`](../reference/README.md) — where the reference templates end up diff --git a/design-toolkit/templates/audit-plan.json b/design-toolkit/templates/audit-plan.json new file mode 100644 index 0000000..4bd9d4f --- /dev/null +++ b/design-toolkit/templates/audit-plan.json @@ -0,0 +1,17 @@ +{ + "_note": "STATE 08 — what tools/audit.mjs drives. Optional: with no plan file the harness derives its rows from the hooks in reference/state-machines.json. Write one when you want passes the state machines cannot express (locale × theme × reduced-motion), or when the prototype predates the state machines.", + + "passes": [ + { "name": "base", "query": "" }, + { "name": "alt-locale-dark-rm", "query": "lang=<locale>&mode=dark&rm=1" } + ], + + "screens": [ + { "id": "S-FLOW-01", "label": "S-FLOW-01", "url": "flow.html?view=main" } + ], + + "states": [ + { "label": "flow-empty", "url": "flow.html?view=main&state=empty" }, + { "label": "flow-error", "url": "flow.html?view=main&state=error" } + ] +} diff --git a/design-toolkit/templates/audit-report.md b/design-toolkit/templates/audit-report.md new file mode 100644 index 0000000..c9fef7a --- /dev/null +++ b/design-toolkit/templates/audit-report.md @@ -0,0 +1,56 @@ +<!-- TEMPLATE — audit-report + Written by STATE 08 · full contract: skills/08-self-audit/SKILL.md + Copy into artifacts/ (per-feature name) and fill in. Angle brackets are + placeholders; every heading below is load-bearing for a downstream check. --> + +--- +artifact: audit-report +version: <audit-<feature>-NN> +produced_by: self-audit +reads_versions: + prototype: <proto-<feature>-NN — the exact bytes audited> + requirements.md: <version> + flows.md: <version> + ui-plan.md: <version> + traceability.md: <version> +feature: <feature/flow id> +verdict: pass | fail +--- + +# <audit id> — <feature> — verdict: <PASS | FAIL> + +## Verdict + +<pass|fail> — <N> / <N> acceptance criteria met. <rationale> + +## Method + +<what was actually run: screens driven, runs, assertion count, screenshots +captured, sweeps executed. Name the check class per M1 — computed visibility +and geometry, not DOM presence.> + +## Findings + +| ID | Sev | Finding | Caught by | Status | +|---|---|---|---|---| +| AF-1 | blocker | <defect> | screenshot \| assertion \| sweep | fixed in-loop | +| AF-2 | major | <defect> | <method> | open | +| AF-3 | minor | <defect> | <method> | recorded → debt #N | + +## Conformance matrix + +| AC | Requirement | Met | Evidence | +|---|---|---|---| +| AC1 | <text> | met | <screen + probe or screenshot ref> | +| AC7 | <text> | unmet | <what is missing> | +| AC9 | <text> | waived | <who waived, why, recorded where> | + +## Harness corrections + +<per M3 — probes that failed and turned out to be the instrument's fault, with +the correction made. Recorded, because an uncorrected harness re-reports them +next run.> + +## Known limitations + +<carried to USER_REVIEW for transparent presentation> diff --git a/design-toolkit/templates/edge-annotations.json b/design-toolkit/templates/edge-annotations.json new file mode 100644 index 0000000..5be9f62 --- /dev/null +++ b/design-toolkit/templates/edge-annotations.json @@ -0,0 +1,67 @@ +{ + "_note": "STATE 12 / E6 — developer annotations. Four fields per edge, two per frame. Consumed by tools/annotate.mjs against artifacts/navgraph.json. The load-bearing rule: UNKNOWN is a legal value and a guessed value is not — a blank field fails (V12), and an `api` field filled with a plausible endpoint is worse than an empty one, because the developer will build it.", + + "version": "edge-annotations-01", + "produced_by": "flow-visualization (E6 — developer annotations)", + "date": "<YYYY-MM-DD>", + + "reads": { + "artifacts/navgraph.json": "<sha256>", + "reference/screen-registry.csv": "<sha256>" + }, + + "enums": { + "nav": ["push", "replace", "modal", "sheet", "tab", "back", "deep-link", "UNKNOWN"], + "auth": ["guest-ok", "auth-required", "premium", "UNKNOWN"] + }, + + "rules": [ + "Every edge carries all four fields and every frame both. Blank is a failure (V12); UNKNOWN is legal and counted.", + "Split the fields by who owns the answer: `api` and `anim` are DERIVED (re-run the sweep every time, never trust the recorded claim); `nav` and `guard` are AUTHORED WITH EVIDENCE, each citing the file:line of the call site.", + "State the value the BYTES carry, not the value the plan asked for. Annotating the plan's number produces a document that is wrong in exactly the way a developer cannot detect." + ], + + "edges": [ + { + "from": "S-FLOW-01", + "to": "S-FLOW-02", + "label": "<the trigger, as the registry names it>", + "crossFlow": false, + "nav": { + "value": "push", + "source": "flow.html:366", + "note": "<which control does this, and anything a developer needs about the back stack>" + }, + "anim": { + "value": "none", + "source": "flow.html:64", + "note": "<the motion preset the page actually declares, or none>" + }, + "api": { + "value": "none (simulated)", + "source": "network sweep", + "note": "<re-derived every run; E11 fails if the sweep disagrees with this claim>" + }, + "guard": { + "value": "<the flow graph's guard expression, or UNKNOWN>", + "source": "flows-<feature>.md D1", + "note": "" + } + } + ], + + "frames": { + "S-FLOW-01": { + "auth": { + "value": "guest-ok", + "source": "<file:line of the check, or the artifact that rules it>", + "note": "" + }, + "perm": { + "value": "none", + "source": "ux-plan-<feature>.md:<line>", + "note": "<the OS permission this screen needs, or none>" + } + } + } +} diff --git a/design-toolkit/templates/flow-visualization.md b/design-toolkit/templates/flow-visualization.md new file mode 100644 index 0000000..cdf8765 --- /dev/null +++ b/design-toolkit/templates/flow-visualization.md @@ -0,0 +1,34 @@ +<!-- TEMPLATE — flow-visualization + Written by STATE 12 · full contract: skills/12-flow-visualization/SKILL.md + Copy into artifacts/ (per-feature name) and fill in. Angle brackets are + placeholders; every heading below is load-bearing for a downstream check. --> + +--- +artifact: flow-visualization +version: navmap-<scope>-NN +produced_by: flow-visualization +reads_versions: { screen-registry.csv: <sha>, flows-<feature>.md: <version>, prototype: proto-<feature>-NN } +scope: <the flows this map covers — stated as scope, per skills/05> +figma: { file: <key>, pages: [<name · node-id>] } +gate: { developer_handoff: granted|pending|waived, date: <date> } +--- + +# Navigation Map — <scope> + +## Sections built +| Section | Journey | Screens | Edges | Figma node | + +## Findings at gate +| Severity | Code | Subject | Status (cleared / waived + rider debt) | + +## Boundary status (W8) +| Port | Owning flow | Status | Checked on | + +## Sync record (W7) +| Date | Trigger | Edge-set delta | Actions taken | + +## Extensions +| Ext | Status | Evidence | +| E1 swimlanes | <n>/<n> screens laned | reference/nav-lanes.json | +| E3 heatmap | derived | navgraph.json.heat | +| E4 deep links | <n>/<n> flows addressable | navgraph.json.deepLinks | diff --git a/design-toolkit/templates/flows.md b/design-toolkit/templates/flows.md new file mode 100644 index 0000000..3c49e20 --- /dev/null +++ b/design-toolkit/templates/flows.md @@ -0,0 +1,73 @@ +<!-- TEMPLATE — flows + Written by STATE 05 · full contract: skills/05-flow-generation/SKILL.md + Copy into artifacts/ (per-feature name) and fill in. Angle brackets are + placeholders; every heading below is load-bearing for a downstream check. --> + +--- +artifact: flows +version: flow-<feature>-NN +supersedes: <prior version, if any> +produced_by: flow-generation +reads_versions: { ux-plan-<feature>.md: <version>, requirements-<feature>.md: <version> } +feature: <feature/flow id> +folds_in: [<revision rounds / decisions this version absorbs>] +--- + +# Flows — <feature> + +Node ids = prototype views. `⟂` = flow boundary (another flow's screen, mocked). + +## Canon entry paths + +<the named paths a user actually takes in, one line each — these are what a +reviewer checks the prototype against> + +## F1 — <segment name> (<tasks covered>) + +``` +⟂OTHER-01 ──trigger──▶ [D1 <question>?] + D1 no ──▶ NODE-A ──trigger──▶ NODE-B + D1 yes ──▶ NODE-B directly +NODE-B ──[D2 <question>?] + D2 <case> ──▶ NODE-C + D2 <case> ──▶ inline recovery (self-loop; recovery: <what the user can do>) +``` + +**D1:** <the guard, as a checkable expression> · **D2:** <guard> — all exhaustive. + +<prose only where the diagram cannot carry it: contested tap targets, suppression +rules, decisions that changed a branch> + +## F2 — ... + +## Decision log + +| ID | Decision | Ruled by | Date | +|---|---|---|---| +| D-<x>N | <what was decided, and what it replaced> | user / audit / this state | <date> | + +## Reachability report + +| Node | Reachable from | Terminal? | Justification if terminal | +|---|---|---|---| +| NODE-A | entry, NODE-C | no | — | +| NODE-Z | NODE-C | **yes** | <why terminating here is correct> | + +Unreachable nodes: **0**. Dead ends without justification: **0**. + +## Recovery coverage + +| Non-happy state (ux-plan) | Recovery transition | +|---|---| +| TASK-A / error | → retry edge on NODE-B | +| TASK-A / empty | → NODE-A with a route-out CTA | + +## Flow boundaries + +| Boundary node | Owning flow | Status | +|---|---|---| +| ⟂OTHER-01 | <flow> | mocked / real handoff | + +## Open decisions + +- o-<id>: <question> — <which branch it leaves unruled> — <who can rule> diff --git a/design-toolkit/templates/handoff.md b/design-toolkit/templates/handoff.md new file mode 100644 index 0000000..2599ff3 --- /dev/null +++ b/design-toolkit/templates/handoff.md @@ -0,0 +1,87 @@ +<!-- TEMPLATE — handoff + Written by STATE 11 · full contract: skills/11-final-output/SKILL.md + Copy into artifacts/ (per-feature name) and fill in. Angle brackets are + placeholders; every heading below is load-bearing for a downstream check. --> + +# <Project> — <Feature> · Design Handoff + +_Feature: **<feature>** · State: **DONE** (Primary User Approval granted <date>, +<n>th pass, scoped to `proto-<feature>-NN`) · Date: <date>_ + +> <standing caveats that apply to everything below — provisional tokens, +> unreviewed copy, simulated content.> + +## What this delivers + +| Screen | ID | Built | +|---|---|---| +| <name> | S-XXX-NN | <what actually ships on it> | + +**States:** <every state a screen can be driven into> + +## Requirement source + +<the brief, the registry rows, or the promises already frozen into other flows. +Say which — a flow built with no user brief is a different object from one built +to a spec, and the receiving team needs to know.> + +## Key decisions + +- **D-XN — <decision>.** <the ruling and why it went that way.> + +## Pipeline artifacts + +requirements-<feature> (<n>R / <n> ACs) → research-<feature> → +product-review-<feature> (**proceed**, D-X0..) → ux-plan-<feature> → +flows-<feature> → ui-plan-<feature> → traceability-<feature> → +**prototype `proto-<feature>-NN`** → audit-report-<feature> (**PASS n/n**) → +review-record-<feature>. + +**Figma:** <file id + page, or PENDING with what blocks it.> + +## Acceptance criteria + +- **audit-<feature>-NN PASS — n / n ACs** (run against the frozen bytes, P4). +- <headless assertion count, run stability, console sweep, hex conformance, + screenshot count.> +- Superseded ACs: <n>, each naming the ratified revision that replaced it (P3). + +## Freeze + +| File | sha256 | +|---|---| +| `prototype/<file>.html` | `<sha256>` | + +## Review packet + +``` +prototype/run-local.sh → play.html #<feature> +``` + +Hooks: <every deep-link hook, from traceability — this is what makes the +deliverable re-drivable after the loop closes.> + +## Waivers + +| Rule | Waived because | Rider | What closes it | +|---|---|---|---| +| V2 / §8 rule 4 | <reason> | debt #<n> | <the specific action> | + +_A waived rule ships only with a grantor, a rider and a closing condition (P6)._ + +## Known limitations + +| ID | Limitation | +|---|---| +| **o-xN** | <the open, what ships instead, what would close it — at full strength (P8).> | + +## §8 Completion Rules + +| # | Rule | Holds | Evidence | +|---|---|---|---| +| 1 | `current_state` = `DONE` | ✅ | machine_state written this edit | +| 2 | approval scoped to the final frozen versions | ✅ | review-<feature>-NN `reads_versions` + freeze sha | +| 3 | `C_ALL_CRITERIA_MET` | ✅ | traceability-<feature>: n met / n waived / n superseded / **0 unmet** | +| 4 | `C_AUDIT_PASS` on the final version | ✅ | audit-<feature>-NN, run on the frozen sha | +| 5 | deliverable + handoff + traceability + decision log exist | ✅ | this folder | +| 6 | no `open` items in the latest `revision-log.md` | ✅ | rev-<feature>-NN | diff --git a/design-toolkit/templates/machine_state.yaml b/design-toolkit/templates/machine_state.yaml new file mode 100644 index 0000000..3c57590 --- /dev/null +++ b/design-toolkit/templates/machine_state.yaml @@ -0,0 +1,72 @@ +# <product> — global machine state +# +# This file is NOT an artifact. It is the machine's own record, and §8 completion +# rule 1 is defined over it. Write it AT DECISION TIME, in the same edit as the +# thing it records — a record written later is a reconstruction. On the run this +# toolkit came from, this file sat two days and two approval rounds stale while +# the machine reported itself shipping, and nothing detected it, because nothing +# was reading the file the completion rule is defined over. +# +# Side effect worth knowing: templates/prototype/serve.py gates live reload on the +# top-level `current_state` being USER_REVIEW (skills/09 G1). + +machine_state: + project: <slug> + current_state: REQUIREMENT_ANALYSIS + scope: <the feature this run is about> + gate: pending # pending | granted | denied + handoff_required: false # true → STATE 12 runs before FINAL_OUTPUT + blocked_reason: null + + last_transition: + from: null + to: REQUIREMENT_ANALYSIS + trigger: <what caused it — quote the instruction where there is one> + ts: "<YYYY-MM-DD>" + + # Per-state visit counter, for retry ceilings (C_RETRY_OK) + entry_count: {} + + # Per-loop iteration counter (C_LOOP_OK). Ceilings live in toolkit.config.json. + loop_count: + L_CLARIFY: 0 + L_RESEARCH: 0 + L_UX_EDGE: 0 + L_REVISION: 0 + L_AUDIT_FIX: 0 + + # An approval is scoped to the artifact versions it saw. If those change, the + # gate reverts to `pending` — this is the stale-approval rule (§5), and it is + # the anti-pattern the machine most often violates by accident. + approvals: + ClarificationGate: pending + DirectionApprovalGate: pending + PrimaryUserApprovalGate: pending + ConflictMiniGate: pending + DeveloperHandoffGate: pending + + artifact_versions: {} + + # One row per feature. `deliverable` names the folder the freeze landed in — + # `designed` and `delivered` are different claims (skills/11 P2). + flows: [] + # - id: <feature> + # state: <machine state> + # gate: pending | granted + # prototype: proto-<feature>-NN + # audit: audit-<feature>-NN + # deliverable: null + + # sha256 per frozen file. A freeze is a hash, not a copy (skills/11 P2). + freeze: {} + + # ---- §8 Completion Rules ---- + # Each gets a boolean AND a one-line reason. `true` with no reason is the + # silence skills/11 P6 forbids. + completion_check: + C1_state_is_done: false # <reason> + C2_approval_scoped_to_final_frozen_versions: false # <reason> + C3_all_criteria_met: false # <reason> + C4_audit_pass_on_final_version: false # <reason> + C5_deliverables_complete: false # <reason> + C6_no_open_revision_items: false # <reason> diff --git a/design-toolkit/templates/nav-lanes.json b/design-toolkit/templates/nav-lanes.json new file mode 100644 index 0000000..c2ea48b --- /dev/null +++ b/design-toolkit/templates/nav-lanes.json @@ -0,0 +1,10 @@ +{ + "_note": "Swimlane assignment for STATE 12 (E1). NODE lanes only — edge-level system/api attribution lives in the E6 annotation columns, not here. A screen with no lane is reported by tools/navgraph.mjs as N8 and never guessed into a lane: a wrong lane reads as a ruling about who owns a screen.", + "order": ["customer", "admin", "system", "api"], + "lanes": { + "customer": [], + "admin": [], + "system": [], + "api": [] + } +} diff --git a/design-toolkit/templates/product-review.md b/design-toolkit/templates/product-review.md new file mode 100644 index 0000000..1f3721b --- /dev/null +++ b/design-toolkit/templates/product-review.md @@ -0,0 +1,50 @@ +<!-- TEMPLATE — product-review + Written by STATE 03 · full contract: skills/03-product-review/SKILL.md + Copy into artifacts/ (per-feature name) and fill in. Angle brackets are + placeholders; every heading below is load-bearing for a downstream check. --> + +--- +artifact: product-review +version: <hash-or-incrementing-id> +produced_by: product-review +reads_version: + requirements: <requirements.md version consumed> + research: <research.md version consumed> +recommendation: proceed | re-scope | stop +gate: direction-approval +gate_state: pending | approved | denied +--- + +## Recommendation + +**<proceed | re-scope | stop>** — <rationale, tied to the scores and +contradictions below. Name the two or three findings that actually drove it.> + +## Prioritized requirements + +| ID | Requirement | Value | Effort | Risk | Band | Evidence | +|---|---|---|---|---|---|---| +| R1 | <text, verbatim from requirements.md> | H/M/L | H/M/L | H/M/L | must | [T1, T4] | +| R2 | ... | | | | should | [T2] | +| R7 | ... | | | | cut | unevidenced | + +## Risk register + +| ID | Risk | Sev | Mitigation **or** accept-risk | Owner | +|---|---|---|---|---| +| K1 | <risk statement> | high | <mitigation> | <role> | +| K2 | <risk statement> | high | **ACCEPTED** — <why, and by whom> | <role> | + +## Scope contradictions + +- X1: <brief wants A> vs <research theme T3 shows B> — <resolution, or + explicitly left open with an open-decision id> + +## Decision record + +- D1: <decision> — trigger: <what evidence or event would reverse it> +- Deferred: <open decisions handed to later states, with ids> + +## Cut list + +- <requirement id> — <why it is out for this cycle, and what would bring it back> diff --git a/design-toolkit/templates/prototype/README.md b/design-toolkit/templates/prototype/README.md new file mode 100644 index 0000000..ba6fe6d --- /dev/null +++ b/design-toolkit/templates/prototype/README.md @@ -0,0 +1,93 @@ +# `templates/prototype/` — the Run Local review player + +[← templates/](../README.md) · [Repository root](../../README.md) · [STATE 09](../../WORKFLOW_GUIDE.md#state-09--user_review) + +--- + +## Purpose + +The review chrome. Three files that STATE 07 copies into `artifacts/prototype/` so STATE 09 can serve the prototype over HTTP and present it through a player with a sidebar — rather than by opening raw HTML files. + +**The player is not a convenience.** Rule G1 exists because reviewing raw pages bypasses the review chrome, and that was a direct user correction on a real run. STATE 09's V4 requires the review to be conducted against a served prototype, with the **player URL recorded** in the gate record. + +| File | What it does | +|---|---| +| [`run-local.sh`](run-local.sh) | Starts (or reuses) the server and opens the player. Idempotent — re-running is "Refresh Run Local". | +| [`serve.py`](serve.py) | Static server plus live reload. Reload is **gated on workflow state**. | +| [`play.html`](play.html) | The player: a sidebar of registered flows, walkthrough progress, *open standalone*, and a stage iframe. | + +## Inputs + +| Input | Where from | +|---|---| +| The prototype pages | `artifacts/prototype/*.html`, built by STATE 07 | +| The `FEATURES` array | Edited inside `play.html`, one entry per prototype page | +| Port | `run-local.sh <port>`, default **8765**, matching `toolkit.config.json` → `review.port` | +| Workflow state | `state/machine_state.yaml` → top-level `current_state`, or `$TOOLKIT_STATE_FILE` | + +## Outputs + +- A local HTTP server on the chosen port. +- The player at `http://localhost:<port>/play.html`. +- **The player URL**, which is the thing STATE 09 records in `review-record-<feature>.md`. + +## Examples + +**Standing it up** (STATE 07, in the same edit as the first prototype page): + +```bash +cp templates/prototype/{run-local.sh,serve.py,play.html} artifacts/prototype/ +chmod +x artifacts/prototype/run-local.sh +``` + +**Registering a flow.** In `play.html`, one entry per page — added in the **same edit that creates the page**: + +```js +const FEATURES = [ + { id:'signin', pri:'P0', file:'signin.html', + label:'Sign in (email + password, reset)', + desc:'S-SIGN-01…03 · ?view=main|error|reset' }, +]; +``` + +**Running the review:** + +```bash +artifacts/prototype/run-local.sh # → http://localhost:8765/play.html +artifacts/prototype/run-local.sh 9000 # a different port +``` + +**Deep-linking into a flow and a state.** The player splits `#id?query`, so a hook survives into the stage iframe: + +``` +http://localhost:8765/play.html#signin +http://localhost:8765/play.html#signin?view=main&state=error +``` + +That is what makes STATE 07's hook table usable as a review packet: the user reaches the error state in one step instead of clicking toward it. + +**What "Refresh Run Local" means.** Re-running the script is idempotent: a live-reload server already on the port is reused; a *plain* server on the port is replaced with the live-reload one. + +## Best practices + +- **Review through the player, never by opening a page directly.** The sidebar *is* the intended review chrome, and V4 asks for the URL specifically. +- **Register every new page in `FEATURES`, in the same edit that creates the page.** A flow missing from the sidebar is a flow the user does not review — the array renders an explicit "No prototype registered yet" message precisely so its emptiness is not mistaken for an empty prototype. +- **Ship the hook list with the verdict request.** STATE 07's traceability table already names a deep-link hook for every flow state, variant and error case. That table is the packet. +- **Check the state file before calling live reload broken.** Reload is active **only** while the top-level `current_state` in `state/machine_state.yaml` is `USER_REVIEW`. Outside review the same server serves plain pages, `/__watch` answers "off", and no poller is injected. This is checked per request, so a state transition applies without restarting the server. +- **A missing or unreadable state file means reload stays ON.** That is deliberate, for standalone use outside a run. +- **Record the player URL in the gate record.** On the extraction run, three of six records named the player and hook in prose and **none recorded a URL** — so the rule was satisfied in practice and unevidenced in the artifact. +- **The player is harness chrome, not product surface.** `play.html`, `run-local.sh` and `serve.py` are excluded from the STATE 08 palette sweep and the STATE 12 network sweep by `review.harnessFiles` — the player is added to that set automatically, so you only list chrome you add yourself. Without the exclusion the sweep reports the player's own colours as findings on every run, and a recurring false positive teaches a reader to stop reading the report. +- **Do not edit these files in `templates/`.** Copy them; edit the copies. The templates are the shape for the next product too. + +## Requirements + +- **Python 3** for `serve.py`. +- A shell with `lsof` and `curl` for `run-local.sh`'s reuse detection (macOS and most Linux distributions). +- `open` is used to launch the browser where available; otherwise the script prints the URL. + +## Related + +- [`WORKFLOW_GUIDE.md § STATE 09`](../../WORKFLOW_GUIDE.md#state-09--user_review) — the review method, G1–G8 +- [`skills/09-user-review/SKILL.md`](../../skills/09-user-review/SKILL.md) — the full contract +- [`skills/07-prototype/SKILL.md`](../../skills/07-prototype/SKILL.md) — B2, the hooks that make the packet +- [`ARTIFACT_FLOW.md`](../../ARTIFACT_FLOW.md) — what the gate record must carry diff --git a/design-toolkit/templates/prototype/play.html b/design-toolkit/templates/prototype/play.html new file mode 100644 index 0000000..9e38f16 --- /dev/null +++ b/design-toolkit/templates/prototype/play.html @@ -0,0 +1,174 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8" /> +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> +<title>Prototype Player + + + + +
    + Product prototypes + Features Prototyping +
      + open standalone ↗ +
      + + + + + + + diff --git a/design-toolkit/templates/prototype/run-local.sh b/design-toolkit/templates/prototype/run-local.sh new file mode 100755 index 0000000..2a6ecfd --- /dev/null +++ b/design-toolkit/templates/prototype/run-local.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Run Local — default USER_REVIEW step (docs/workflow.md STATE 09, skills/09 G1). +# Copied into artifacts/prototype/ by STATE 07; run it from there. +# Serves prototype/ over HTTP (serve.py — live reload: any file change in +# prototype/ auto-refreshes the browser, but ONLY while the workflow is in +# USER_REVIEW per state/machine_state.yaml) and opens the player (play.html). +# Idempotent: re-run = "Refresh run local" — reuses a live server, just reopens player. +# A plain server (no live reload) already on the port gets replaced. +set -euo pipefail + +PORT="${1:-8765}" +DIR="$(cd "$(dirname "$0")" && pwd)" +URL="http://localhost:${PORT}/play.html" + +up() { lsof -nP -iTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; } +live_reload() { curl -sf "http://localhost:${PORT}/__watch" >/dev/null 2>&1; } + +if up && live_reload; then + echo "refresh: live-reload server already on :${PORT} — reusing" +else + if up; then + echo "upgrade: plain server on :${PORT} — replacing with live-reload server" + lsof -nP -tiTCP:"${PORT}" -sTCP:LISTEN | xargs kill 2>/dev/null || true + sleep 0.3 + fi + echo "start: serving ${DIR} on :${PORT} (live reload)" + (cd "${DIR}" && nohup python3 serve.py "${PORT}" >/dev/null 2>&1 &) + # wait until up (max ~3s) + for _ in $(seq 1 30); do + curl -s -o /dev/null "${URL}" && break + sleep 0.1 + done +fi + +echo "player: ${URL}" +if command -v open >/dev/null 2>&1; then open "${URL}"; fi diff --git a/design-toolkit/templates/prototype/serve.py b/design-toolkit/templates/prototype/serve.py new file mode 100644 index 0000000..4c03a36 --- /dev/null +++ b/design-toolkit/templates/prototype/serve.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Run Local dev server — static serve + live reload (docs/workflow.md STATE 09). + +Lives in artifacts/prototype/ (copied from templates/prototype/ by STATE 07). + +Serves this directory and injects a tiny poller into every .html response; +any file change in the tree reloads the browser automatically. +Endpoint /__watch returns the newest mtime in the tree (also used by +run-local.sh to detect a live-reload-capable server vs a plain one). + +Live reload is active ONLY while the workflow is in the USER_REVIEW state +(state/machine_state.yaml top-level current_state). Outside review the +server still serves pages, but /__watch answers "off" and no poller is +injected — checked per request, so a state transition applies without +restart. Missing/unreadable state file = reload ON (standalone use). +""" +import os +import re +import sys +from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler + +DIR = os.path.dirname(os.path.abspath(__file__)) +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 +STATE_FILE = os.environ.get('TOOLKIT_STATE_FILE') or os.path.abspath( + os.path.join(DIR, '..', '..', 'state', 'machine_state.yaml')) + + +def in_review(): + try: + with open(STATE_FILE, encoding='utf-8') as f: + for line in f: + m = re.match(r'^ current_state:\s*(\S+)', line) + if m: # first 2-space-indented hit = machine_state's own field (parked flows nest deeper) + return m.group(1) == 'USER_REVIEW' + except OSError: + pass + return True + +SNIPPET = (b"") + + +def latest_mtime(): + mx = 0 + for root, _dirs, files in os.walk(DIR): + for f in files: + try: + mx = max(mx, os.stat(os.path.join(root, f)).st_mtime_ns) + except OSError: + pass + return str(mx) + + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=DIR, **kwargs) + + def log_message(self, *args): + pass + + def _send(self, body, ctype): + self.send_response(200) + self.send_header('Content-Type', ctype) + self.send_header('Cache-Control', 'no-store') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + live = in_review() + if self.path.startswith('/__watch'): + self._send(latest_mtime().encode() if live else b'off', 'text/plain') + return + if not live: + super().do_GET() + return + path = self.translate_path(self.path.split('?', 1)[0].split('#', 1)[0]) + if os.path.isdir(path): + path = os.path.join(path, 'index.html') + if path.endswith('.html') and os.path.isfile(path): + with open(path, 'rb') as f: + body = f.read() + if b'' in body: + body = body.replace(b'', SNIPPET + b'\n', 1) + else: + body += SNIPPET + self._send(body, 'text/html; charset=utf-8') + return + super().do_GET() + + +if __name__ == '__main__': + print(f'serve.py: {DIR} on :{PORT} (live reload on)') + ThreadingHTTPServer(('', PORT), Handler).serve_forever() diff --git a/design-toolkit/templates/requirements.md b/design-toolkit/templates/requirements.md new file mode 100644 index 0000000..54ce120 --- /dev/null +++ b/design-toolkit/templates/requirements.md @@ -0,0 +1,39 @@ + + +--- +artifact: requirements +version: +produced_by: requirement-analysis +scope_class: small | medium | large +effort_tier: +--- + +## Problem statement + + +## Goals +- G1: +- G2: + +## Actors +- : + +## Constraints +- + +## Non-goals +- + +## Requirements & acceptance criteria +- R1: + - AC1.1: + - AC1.2: ... + +## Assumptions +- A1 [assumed|confirmed]: + +## Open questions +- Q1 [blocking|non-blocking]: diff --git a/design-toolkit/templates/research.md b/design-toolkit/templates/research.md new file mode 100644 index 0000000..3882acb --- /dev/null +++ b/design-toolkit/templates/research.md @@ -0,0 +1,41 @@ + + +--- +artifact: research +version: +produced_by: research +reads_version: +coverage: +--- + +## Themes +- T1: + - sources: [S1, S3] + - relevance: + - maps_to: [G1, G2] # goal IDs from requirements.md +- T2: ... + +## Evidence & citations +- S1 [resolvable]: +- S2 [resolvable]: + +## Competitor notes +- : (sources: [S2]) + +## Pattern catalog +- P1: (sources: [S4]) + +## Constraints +- (sources: [S5]) + +## Contradictions +- C1: vs + +## Goal coverage +- G1 → [T1] | G2 → [T2] | G3 → no-research-needed + +## Gaps +- GAP1: [reason] diff --git a/design-toolkit/templates/review-record.md b/design-toolkit/templates/review-record.md new file mode 100644 index 0000000..572e5f4 --- /dev/null +++ b/design-toolkit/templates/review-record.md @@ -0,0 +1,82 @@ + + +--- +artifact: review-record +version: review--NN +produced_by: user-review +reads_versions: + prototype: -NN — the exact bytes reviewed> + audit-report.md: -NN> +feature: +date: +gate: PrimaryUserApprovalGate +verdict: APPROVED | REQUEST-CHANGES | REJECTED +player_url: http://localhost:8765/play.html# +--- + +# Gate record — + +## Decision + +**** on the **Nth presentation**, . + +User instruction, verbatim: *""*. + +| Field | Value | +|---|---| +| Flow | | +| Prototype at decision | `` | +| Audit of record | `` — | +| Reviewed at | `` (Run Local rule V4) | +| Passes to approval | | + +## What was approved + + + +## Verification at approval + +| Check | Result | +|---|---| +| Audit | | +| Assertions (rendering-class) | | +| Console | | +| Screenshots | | + +## Change requests (if `request-changes`) + +| ID | Verbatim ask | Target state | Spec link | Status | +|---|---|---|---|---| +| CR1 | *""* | `UI_PLANNING` | R-x3 / F2 | open → revision-log | + +_No request is dropped (V3). Anything not actioned is recorded as `deferred` +with a reason._ + +## Deltas ratified by this decision + +| # | Delta | Class | Evidence | +|---|---|---|---| +| 1 | | bug-fix only \| **feature** | | + +## Known limitations presented + + + +## Opens carried forward + +| ID | Question | Ships as | +|---|---|---| + +## Validations waived + +| Rule | Why | Granted by | Rides on | +|---|---|---|---| + +## Freeze hashes + +| Deliverable file | sha256 | +|---|---| diff --git a/design-toolkit/templates/revision-log.md b/design-toolkit/templates/revision-log.md new file mode 100644 index 0000000..eb7b9dd --- /dev/null +++ b/design-toolkit/templates/revision-log.md @@ -0,0 +1,86 @@ + + +--- +artifact: revision-log +version: rev--NN +produced_by: revision +reads_versions: + review-record.md: review--NN + audit-report.md: audit--NN +iteration: +loop: L_REVISION (/3) # L_AUDIT_FIX (/3) if this is an audit-fix cycle +feature: +--- + +# Iteration , + +## Change set + +| # | Change item | Raised by | Class + sweep | Root cause | Target state | Status | +|---|---|---|---|---|---|---| +| CR1 | *""* | user-review \| self-audit | | | `UI_PLANNING` → `PROTOTYPE` | resolved \| deferred \| superseded | + +_Every item carries a target state and a status (V1). Nothing exits `open` (V2)._ + +## Conflicts — Conflict Mini-Gate + +| # | Conflict | Side A | Side B | Ships as | Gate outcome | +|---|---|---|---|---|---| +| o-x1 | | | | | pending user ruling \| ruled | + +_No conflict is resolved inside the prototype (R8, V6)._ + +## Dependency order (upstream → downstream) + +1. `` → () +2. `PROTOTYPE` → proto--NN () +3. `SELF_AUDIT` → audit--NN +4. `USER_REVIEW` → gate reverts to `pending` (stale-approval rule §5) + +## Superseded by this revision + +| Component / rule / string | Superseded by | Removed from | +|---|---|---| + +## Constraints recorded + + + +## Deliberately not changed + +| Item | Why not | +|---|---| + +_Recorded so silence is not mistaken for oversight (V2)._ + +## Impact analysis + +| Artifact | Effect | +|---|---| +| requirements.md | unchanged | +| ui-plan.md | **ui--NN** (supersedes -NN) | +| prototype | **proto--NN** | +| audit-report.md | **audit--NN** | + +## Re-validation + +| Check | Result | +|---|---| +| Re-audit on the rebuilt bytes | `audit--NN` PASS N / N — or **WAIVED**, rider: debt # | +| Rendering-class assertions | N / N | +| Class sweeps from R2 | | + +## Validation self-check + +- **V1** ✅ / ❌ — every item has a target state and a status. +- **V2** ✅ / ❌ — no item left `open`. +- **V3** ✅ / ❌ — iteration incremented; `L_REVISION /3`, `L_AUDIT_FIX /3`. +- **V4** ✅ / ❌ — every item names its class and sweep result. +- **V5** ✅ / ❌ — superseded components removed; carried items re-verified against current bytes. +- **V6** ✅ / ❌ — conflicts recorded with both sides; none resolved in the bytes. +- **V7** ✅ / ❌ — return edge passes through `SELF_AUDIT`, or a waiver names its rider. + +**Exit:** dispatch to → `SELF_AUDIT` → `USER_REVIEW`. diff --git a/design-toolkit/templates/screen-registry.csv b/design-toolkit/templates/screen-registry.csv new file mode 100644 index 0000000..6424d31 --- /dev/null +++ b/design-toolkit/templates/screen-registry.csv @@ -0,0 +1,3 @@ +screen_id,flow,screen_name,purpose,data_content,key_components,states,entry_from,navigates_to,status,notes +S-FLOW-01,01 Flow Name,Screen Name,What this screen is for in one line,"the data it shows","the components it is built from","happy, loading",app launch,S-FLOW-02 | S-OTHER-01 (condition),todo,"free notes — frozen version, open questions, anything a later reader needs" +S-FLOW-02,01 Flow Name,Second Screen,…,"…","…","happy, error{invalid-input}",S-FLOW-01,S-FLOW-01 (back) | S-OTHER-01,todo,"" diff --git a/design-toolkit/templates/state-machines.json b/design-toolkit/templates/state-machines.json new file mode 100644 index 0000000..df1caaa --- /dev/null +++ b/design-toolkit/templates/state-machines.json @@ -0,0 +1,54 @@ +{ + "_note": "STATE 12 / E5 — per-screen state machines. The NODE set is derived from the registry's `states` column (tools/stategraph.mjs fails the run if a machine adds or drops one). The EDGE set is AUTHORED WITH EVIDENCE: transitions live in the prototype's control flow and cannot be derived, so each carries `evidence` as file:line and the tool resolves that line against the frozen bytes. An unevidenced arrow looks like a spec and is a guess.", + + "meta": { + "version": "state-machines-01", + "derived_from_registry_sha": "", + "date": "" + }, + + "screens": { + "S-FLOW-01": { + "initial": "happy", + "states": { + "happy": { + "hook": "flow.html?view=main", + "note": "reachable by default entry" + }, + "loading": { + "hook": "flow.html?view=main&load=1", + "entry_only": true, + "note": "only ever built on arrival — no in-screen path back into it" + }, + "empty": { + "hook": null, + "unimplemented": true, + "reason": "declared in the registry; the frozen bytes never render it — S9 fails if this reason is missing" + } + }, + "transitions": [ + { + "from": "loading", + "to": "happy", + "trigger": "data settles", + "kind": "system", + "evidence": "flow.html:412" + } + ], + "_node_flags": [ + "entry_only — real, but only ever built on arrival (no in-screen path in)", + "terminal — no outbound change by design; the way out is leaving the screen", + "unimplemented — the registry declares it and the bytes never render it. Drawn dashed, kept, and REQUIRES a `reason`. Deleting it would make the deliverable agree with itself by deleting the disagreement." + ] + } + }, + + "id_conflicts": [ + { + "screen": "", + "prototype_view": "", + "prototype_sid": "", + "note": "measured by tools/stateprobe.mjs, not asserted. Recorded, not reconciled — renumbering is a registry decision." + } + ] +} diff --git a/design-toolkit/templates/state-vocabulary.md b/design-toolkit/templates/state-vocabulary.md new file mode 100644 index 0000000..454d8d8 --- /dev/null +++ b/design-toolkit/templates/state-vocabulary.md @@ -0,0 +1,74 @@ +# State Vocabulary — + +_Owner: STATE 12 `flow-visualization` (E5). Established ._ + +The registry's `states` column is a **closed vocabulary**, not free text. This file +is the term set, the qualifier rule, and the original → normalized mapping. + +**Why a closed set.** On the run this toolkit was extracted from, the registry +carried 59 distinct free-text labels across 48 screens — including three spellings +of "empty for a new user". Each was locally sensible. The set was not a state +machine, and no tool could tell whether two labels meant one concept or two. +**Normalize first, then generate**: generating first freezes N private +vocabularies into a deliverable. + +## Syntax + +``` + a state with no further distinction +{} the same state, with the case that produced it +``` + +The qualifier is kebab-case and is **never dropped**: `error{wrong-code}` and +`error{unchecked-terms}` are different screens' different recoveries, and a +developer needs both. + +`tools/navgraph.mjs` enforces the term set (`N11-state-vocab`) and the syntax +(`N11-state-syntax`). `tools/stategraph.mjs` enforces the same set (`S1-vocab`). +Both **import** it from `CANON_STATES` in `tools/config.mjs`, which is the single +definition — adding a term means editing that constant and this file, and nothing +else. Two steps, deliberately: the constant is what the tools check, and this file +is where the justification lives. + +## Canonical states (14) + +| State | Means | +|---|---| +| `happy` | the normal path for this screen | +| `loading` | content in flight, screen already committed | +| `empty` | screen is correct and has nothing to show | +| `error` | an operation failed and the user can retry or correct | +| `fail` | a terminal negative outcome (not retryable in place) | +| `success` | a terminal positive outcome | +| `in-progress` | work in flight that the user started and can watch | +| `timeout` | an operation exceeded its window | +| `guest` | signed-out degradation of the screen | +| `locked` | the user is blocked from the content by a gate (level, purchase, capacity, quota) | +| `confirm` | a blocking confirmation awaiting a user decision | +| `filtered` | a list narrowed by user filter or search, **with** results | +| `offline` | no connectivity | +| `permission-denied` | an OS or role permission blocks the screen | + +A vocabulary is a set of **permitted** terms, not an inventory of used ones — +declaring a term with zero uses is correct. + +`filtered` means **narrowed with results**. A filter that returns nothing is +`empty`, with the filter as the qualifier (`empty{category}`). + +## Adding a term + +**Adding a canon term costs a justification, written here.** Fold it into an +existing term unless doing so would be a false statement about the screen. A term +only one screen would ever use is a **qualifier**, not a canon term. + +| Term added | Date | Why folding it in would have been false | +|---|---|---| +| | | | + +## Mapping — original → normalized + +Preserve the originals. The rewrite must lose nothing. + +| Original label | Normalized | Screens | +|---|---|---| +| | | | diff --git a/design-toolkit/templates/traceability.md b/design-toolkit/templates/traceability.md new file mode 100644 index 0000000..bfa0320 --- /dev/null +++ b/design-toolkit/templates/traceability.md @@ -0,0 +1,62 @@ + + +--- +artifact: traceability +version: trace--NN +produced_by: prototype +reads_versions: + requirements-.md: + ux-plan-.md: + flows-.md: + ui-plan-.md: +feature: +--- + +# Traceability — (-NN>) + +Every prototype element traces to a spec entry (V2), and every flow state is +represented (V1). Files: `artifacts/prototype/`. + +## Requirement → task → flow → component → prototype element + +| Req | Task | Flow | UI component | Prototype element / hook | +|---|---|---|---|---| +| R-x1 | TK1 | F1 | | ``, ``, `?hook=` | + +## Flow state → prototype representation (V1) + +| node | Representation | Hook | +|---|---|---| +| | | `?view=…` | + +_Every node in `flows.md`, including recovery and non-happy-path states._ + +## Transition → wiring (V4) + +| Flow transition | Wired as | Destination paints | +|---|---|---| +| (D) | `` / `goFlow(...)` | yes — | + +## Decision → implementation + +| Decision | Where it lives | +|---|---| +| D-x1 | `` | + +## Superseded — stripped, not left dead (B7) + +| Removed | Superseded by | Selectors / keys stripped | +|---|---|---| +| | | `.a`, `.b`, `strKey1`, `fnName()` | + +## Un-specced additions + + + +## Verification record (B8) + + diff --git a/design-toolkit/templates/ui-plan.md b/design-toolkit/templates/ui-plan.md new file mode 100644 index 0000000..c075c22 --- /dev/null +++ b/design-toolkit/templates/ui-plan.md @@ -0,0 +1,107 @@ + + +--- +artifact: ui-plan +version: ui--NN +produced_by: ui-planning +reads_versions: + flows-.md: + ux-plan-.md: + research-.md: + design-system: "" +feature: +inherits: +supersedes: +--- + +# UI Plan — + +## Inherited, reused verbatim + + + +## STRICT colour allowlist (V5) + +``` + +BANNED: +``` + +Write the same set into `toolkit.config.json` → `audit.colorAllowlist` / +`colorBanned`. That is where STATE 08's harness reads it; an allowlist that lives +only in this document is an allowlist nothing enforces. + +The **BANNED** list is not optional. `SELF_AUDIT` enforces this allowlist by +machine hex-extraction, and the permanent lesson attached to that rule is that +an audit must check **non-DS absence, not just DS presence** — an allowlist +alone cannot catch a value that was never supposed to exist. + +Naming note: this "V5" is the **`SELF_AUDIT` hex-conformance rule id**. It is not +a validation rule of this state — this state's rules are +V1–V4 — and it is a different V5 from the rendering-class rule defined in +[`skills/08`](../skills/08-self-audit/SKILL.md). + +## Component inventory → DS mapping (reuse-first) + +| Comp | Surface | DS mapping | new? | +|---|---|---|---| +| | | `` | reuse | +| | | `` + | reuse+variant | +| | | | **new**, justified: | + + + +## Layout rules () + + + +## Motion spec + +| Element | Default | Reduced | +|---|---|---| +| | | | + + + +## Contrast + + + +## Token reference resolution (V4) + + + +## Superseded + +| Component | Replaced by | Strip in prototype | +|---|---|---| +| | | yes | + +## Extension Note (informational, non-blocking) + + + +## Open decisions + +- o-: + +## Validation self-check + +- **V1** ✅/❌ +- **V2** ✅/❌ +- **V3** ✅/❌ +- **V4** ✅/❌ + +**Exit:** validation passes → `PROTOTYPE` (). diff --git a/design-toolkit/templates/ux-plan.md b/design-toolkit/templates/ux-plan.md new file mode 100644 index 0000000..c53ae79 --- /dev/null +++ b/design-toolkit/templates/ux-plan.md @@ -0,0 +1,72 @@ + + +--- +artifact: ux-plan +version: -NN> +produced_by: ux-planning +reads_versions: + requirements.md: + research.md: + product-review.md: +feature: +coverage: / +--- + +# UX Plan — + +## Primary tasks (→ requirements) + +| Task | User intent | Reqs | +|---|---|---| +| TASK-A | | R1, R4 | +| TASK-B | ... | R2 | + +## Information architecture + + + +## Navigation model + + + +## State enumeration + +### TASK-A +| State | Kind | Trigger | Strategy | +|---|---|---|---| +| happy | happy | | | +| loading | non-happy | | ... | +| empty | non-happy | | ... | +| error | non-happy | | ... | +| interrupted | non-happy | | ... | +| permission-denied | non-happy | | ... | + +### TASK-B +... + +## Edge-case matrix + +| | loading | empty | error | interrupted | offline | permission-denied | +|---|---|---|---|---|---|---| +| TASK-A | ✅ | ✅ | ✅ | ✅ | n/a — | ✅ | +| TASK-B | ✅ | ✅ | ✅ | ✅ | ✅ | n/a — | + +## Accessibility strategy + +- Interactive target floor: — **state the number here**; it becomes an + acceptance criterion the audit checks against, so an unrealistic one becomes + debt later. +- Contrast posture, focus order, keyboard reachability, screen-reader + expectations, motion opt-out, script/locale and per-glyph font handling. + +## UX risks + +- U1: + +## Open decisions + +- o-: diff --git a/design-toolkit/toolkit.config.json b/design-toolkit/toolkit.config.json new file mode 100644 index 0000000..7d43e9f --- /dev/null +++ b/design-toolkit/toolkit.config.json @@ -0,0 +1,82 @@ +{ + "$schema": "./tools/config.schema.json", + + "product": { + "name": "", + "slug": "next-product", + "platform": "mobile", + "viewport": { "width": 393, "height": 852 }, + "locales": ["en"], + "scripts": [], + "note": "viewport is the review/capture viewport for the prototype and the audit harness. `scripts` lists every writing system the product renders, so STATE 08 can prove each one resolves on a stack that carries a face for it — e.g. [{\"name\":\"Khmer\",\"range\":\"\\u1780-\\u17FF\",\"fontMatch\":\"Khmer\"}]. Empty = single-script product, check off." + }, + + "paths": { + "artifacts": "artifacts", + "reference": "reference", + "prototype": "artifacts/prototype", + "shots": "artifacts/shots", + "state": "state/machine_state.yaml", + "registry": "reference/screen-registry.csv", + "lanes": "reference/nav-lanes.json", + "stateMachines": "reference/state-machines.json", + "vocabulary": "reference/state-vocabulary.md", + "edgeAnnotations": "reference/edge-annotations.json" + }, + + "screens": { + "idPattern": "^S-[A-Z0-9]+-[0-9]+[A-Z]?$", + "flowSectionFormat": "FLOW-XXX • Journey Name" + }, + + "review": { + "port": 8765, + "player": "play.html", + "liveReloadWhenState": "USER_REVIEW", + "harnessFiles": ["run-local.sh", "serve.py"], + "note": "harnessFiles are the files in the prototype dir that are review chrome, not product surface. STATE 08's palette sweep and STATE 12's network sweep both exclude this one list — they used to keep separate lists, and the palette sweep reported the player's own colours as findings on every run. `player` is added automatically; do not repeat it here." + }, + + "prototype": { + "viewSelector": ".view", + "activeClass": "active", + "screenSelector": ".screen", + "sidSelector": "#sid", + "minVisibleNodes": 3, + "note": "The contract every harness reads. A flow state is a `viewSelector` element that carries `activeClass` when shown, `data-view` and `data-sid` attributes, and prints its screen id at `sidSelector`. minVisibleNodes is the paint threshold — keep it LOW. An empty state is sparse by design (an illustration, a headline, one line of copy), and a threshold tuned to a busy screen reports the correct rendering of an empty one as a failure. Raise it only if you have a genuinely blank screen it fails to catch." + }, + + "audit": { + "chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "debugPort": 9333, + "servePort": 8791, + "tapTargetFloorPx": 44, + "colorAllowlist": [], + "colorBanned": [], + "benignConsole": ["favicon.ico"], + "note": "colorAllowlist is written by STATE 06 (ui-plan) and enforced by STATE 08. Empty = enforcement off." + }, + + "flowPages": { + "_note": "Screen-id prefix → the prototype page that implements it. Only needed when the page is not `.html` — e.g. { \"ONB\": \"index.html\" }. A prefix with no page is reported as N9, never silently skipped." + }, + + "designSystem": { + "name": "", + "sourceId": "", + "note": "Name the DS by source id. skills/06 recorded failure mode #1: a plan built on the wrong design system validates perfectly against it." + }, + + "figma": { + "fileKey": "", + "pages": {} + }, + + "loops": { + "L_CLARIFY": 3, + "L_RESEARCH": 2, + "L_UX_EDGE": 2, + "L_REVISION": 3, + "L_AUDIT_FIX": 3 + } +} diff --git a/design-toolkit/tools/README.md b/design-toolkit/tools/README.md new file mode 100644 index 0000000..82d9e8a --- /dev/null +++ b/design-toolkit/tools/README.md @@ -0,0 +1,121 @@ +# `tools/` — the validation engine + +[← Repository root](../README.md) · [Validation Engine](../VALIDATION_ENGINE.md) · [Architecture](../ARCHITECTURE.md) + +--- + +## Purpose + +Turn claims into exit codes. Nine Node ≥22 scripts, **zero dependencies**, all config-driven. + +Nothing product-specific lives in this folder. Every tool reads [`toolkit.config.json`](../toolkit.config.json) through [`config.mjs`](config.mjs). **Change a convention in the config, never in a tool.** + +| File | Runs at | Answers | +|---|---|---| +| [`smoke.mjs`](smoke.mjs) | 07 | Does this view paint, any console errors, any target under the floor? | +| [`audit.mjs`](audit.mjs) | 08 | The rendering-class audit: paint, targets, overflow, spill, contrast, script fonts, source sweeps. | +| [`navgraph.mjs`](navgraph.mjs) | 12 | Which screen leads to which screen — derived from the registry. | +| [`stategraph.mjs`](stategraph.mjs) | 12 | Within a screen, which states exist and what moves between them. | +| [`stateprobe.mjs`](stateprobe.mjs) | 12 | Does each state's hook actually **paint**? | +| [`annotate.mjs`](annotate.mjs) | 12 | Per edge: nav kind, motion, API call, guard — each with a resolved citation. | +| [`linkcheck.mjs`](linkcheck.mjs) | CI | Does every internal documentation link resolve, and every anchor name a heading that exists? | +| [`mermaidcheck.mjs`](mermaidcheck.mjs) | CI | Is every Mermaid block well formed, or does it render as a grey error box? | +| [`cdp.mjs`](cdp.mjs) | — | Headless Chrome driver. Not a validator. | +| [`config.mjs`](config.mjs) | — | Root resolution, defaults merge, absolute paths, shared arg parsing. Not a validator. | +| [`config.schema.json`](config.schema.json) | — | JSON Schema for `toolkit.config.json`, for editor completion. | + +## Inputs + +| Source | What comes from it | +|---|---| +| [`toolkit.config.json`](../toolkit.config.json) | viewport · paths · ports · Chrome path · tap-target floor · colour allowlist and ban list · review-chrome files · benign console entries · locales · scripts · the prototype harness contract | +| the repository's `*.md` files | what `linkcheck.mjs` and `mermaidcheck.mjs` read. They take no reference input — the documentation is the input | +| [`artifacts/prototype/`](../artifacts/) | the bytes being driven or swept | +| [`reference/screen-registry.csv`](../reference/) | the navigation model's single source | +| [`reference/state-machines.json`](../reference/) | per-screen transitions with `file:line` evidence, and the hook per state | +| [`reference/edge-annotations.json`](../reference/) | the six annotation fields per edge and frame | +| [`reference/audit-plan.json`](../reference/) | what STATE 08 drives, and the pass matrix. Optional — falls back to the state machines' hooks | +| `artifacts/navgraph.json` | `annotate.mjs` reads the derived edge set | + +## Outputs + +| Tool | JSON | Markdown | Other | +|---|---|---|---| +| `smoke.mjs` | — | — | stdout PASS/FAIL per view | +| `audit.mjs` | `artifacts/audit-data.json` | — | `artifacts/shots/*.png` | +| `navgraph.mjs` | `artifacts/navgraph.json` | `artifacts/navmap-report.md` | — | +| `stategraph.mjs` | `artifacts/stategraph.json` | `artifacts/statemap-report.md` | — | +| `stateprobe.mjs` | `artifacts/stateprobe.json` | — | optional screenshots | +| `annotate.mjs` | `artifacts/annotations.json` | `artifacts/annotate-report.md` | — | +| `linkcheck.mjs` | `--json `, off by default | — | stdout findings + a resolved/total count | +| `mermaidcheck.mjs` | `--json `, off by default | — | stdout findings + a block count | + +**Exit codes, universally:** `0` no findings at or above `--fail-on` · `1` findings · `2` tool error. + +The `1` / `2` distinction is load-bearing. A `2` means the check **did not run**, which is unevaluable, not passing. + +## Examples + +```bash +# STATE 07 — before handing anything to the audit +node tools/smoke.mjs "signin:main,error,reset" "home:dash,stack" + +# STATE 08 — then READ artifacts/shots/ +node tools/audit.mjs --shots artifacts/shots + +# STATE 12 — order matters: annotate reads navgraph.json +node tools/navgraph.mjs --fail-on major +node tools/stategraph.mjs --fail-on major +node tools/stateprobe.mjs +node tools/annotate.mjs --fail-on major + +# CI — no browser needed, so these run on every push +node tools/linkcheck.mjs --fail-on major +node tools/mermaidcheck.mjs --fail-on major +``` + +**Running against a different project root:** + +```bash +node tools/navgraph.mjs --root ../other-product --fail-on major +TOOLKIT_ROOT=../other-product node tools/audit.mjs +``` + +**As a gate check:** + +```bash +set -e +node tools/navgraph.mjs --fail-on major --quiet +node tools/stategraph.mjs --fail-on major --quiet +node tools/stateprobe.mjs --quiet +node tools/annotate.mjs --fail-on major --quiet +echo "READY FOR DEVELOPMENT — scoped to: " +``` + +**Typical output:** + +``` +navgraph: 48 screens · 106 edges · 46 cross-feature · findings 0 blocking / 2 major / 7 advisory + → artifacts/navgraph.json + → artifacts/navmap-report.md + MAJOR N9-deeplink SET: set.html exposes no query hook at all +``` + +## Best practices + +- **A failing probe is a hypothesis, not a finding.** Confirm at source before writing anything into a report. One audit's first run reported 60 failures with 3 real; one state probe reported 37 and **all 37 were the harness**. The catalogue of known false positives is in [VALIDATION_ENGINE.md § 10](../VALIDATION_ENGINE.md#10--the-false-positive-catalogue). +- **Correct the instrument; never waive an unconfirmed failure.** Record the correction, because an uncorrected harness re-reports the same noise next run. +- **Read the screenshots.** `audit.mjs` prints the reminder for a reason: four of one flow's six real defects were screenshot-only finds. +- **Keep `minVisibleNodes` low.** An empty state is sparse by design. A threshold tuned to a busy screen reports correct empty states as failures and blinds the check that catches genuinely blank ones. +- **Filter benign console entries by name**, never wholesale. `audit.benignConsole` exists so a missing favicon does not read as a product defect and a real 404 still does. +- **New product-specific value?** It goes in `toolkit.config.json` and comes through `config.mjs`. A hardcoded value in a tool is how a toolkit fossilises around its first product. +- **Keep dependencies at zero.** A verification layer that rots because of a transitive dependency is not a verification layer. +- **Two sweeps over the same directory must read one exclusion list.** `audit.mjs` sweeps the prototype dir for palette conformance and `annotate.mjs` sweeps it for network calls; both must skip the review chrome. They kept separate lists, they drifted, and the palette sweep reported the player's own colours on every run. The list is now `review.harnessFiles`, resolved once in `config.mjs` with the player always included. +- **A new validator must**: take `--root`, read its config through `config.mjs`, use the shared `SEVERITIES` ladder, distinguish exit `1` from exit `2`, and write both a JSON and a human-readable output. + +## Related + +- [`VALIDATION_ENGINE.md`](../VALIDATION_ENGINE.md) — every tool in detail: checks, output, failure classes, fixes +- [`WORKFLOW_GUIDE.md`](../WORKFLOW_GUIDE.md) — which state runs which tool, and what its exit criteria are +- [`docs/method-rules.md`](../docs/method-rules.md) — the M, W and E rules these tools enforce +- [`toolkit.config.json`](../toolkit.config.json) — everything the tools read diff --git a/design-toolkit/tools/annotate.mjs b/design-toolkit/tools/annotate.mjs new file mode 100644 index 0000000..710e5b2 --- /dev/null +++ b/design-toolkit/tools/annotate.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node +/** + * annotate.mjs — Developer-annotation validator (STATE 12 / DWF-05, E6). + * + * navgraph.mjs answers "which screen leads to which screen". + * stategraph.mjs answers "within a screen, which states exist and what moves between them". + * This answers the question a developer asks *at the connector*: when the app takes this + * route, what kind of navigation is it, what moves on screen, what call does it make, and + * what has to be true first — plus, per frame, who is allowed in and what the OS must grant. + * + * Two inputs: + * artifacts/navgraph.json — the edge set (derived; this tool never invents an edge) + * reference/edge-annotations.json — the four fields per edge + two per frame, each one + * carrying the artifact or `file.html:line` it came from + * + * The load-bearing rule is E6's: **`UNKNOWN` is a legal value and a guessed value is not.** + * A blank field fails (V12). An `api` field filled with a plausible-looking endpoint is worse + * than an empty one, because the developer will build it — so this tool re-runs the network + * sweep itself rather than trusting the recorded claim, and it resolves every cited line + * against the frozen bytes. + * + * Usage: + * node tools/annotate.mjs [--root ] [--json ] [--md ] + * [--fail-on blocking|major|advisory] [--quiet] + * + * Exit codes: 0 = no findings at or above --fail-on, 1 = findings, 2 = tool error. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { loadConfig, parseArgs, SEVERITIES } from './config.mjs'; + +// ---------------------------------------------------------------- args + config + +const { arg, has } = parseArgs(); +const CFG = loadConfig(arg('--root', null)); + +const ROOT = CFG.root; +const ARTIFACTS = CFG.paths.artifacts; +const NAVGRAPH = path.join(ARTIFACTS, 'navgraph.json'); +const ANNOTATIONS = CFG.paths.edgeAnnotations; +const PROTO_DIR = CFG.paths.prototype; +const OUT_JSON = arg('--json', path.join(ARTIFACTS, 'annotations.json')); +const OUT_MD = arg('--md', path.join(ARTIFACTS, 'annotate-report.md')); +const QUIET = has('--quiet'); + +const FAIL_ON = arg('--fail-on', 'blocking'); + +/** E6's closed value sets. Adding a value costs a justification in skills/12 §E6. */ +const NAV_KINDS = new Set(['push', 'replace', 'modal', 'sheet', 'tab', 'back', 'deep-link', 'UNKNOWN']); + +/** Files that live in the prototype dir but are review chrome, not product surface. + * Resolved in config.mjs (`review.harnessFiles`, always including the player) so + * this sweep and audit.mjs's palette sweep cannot drift apart. */ +const HARNESS_FILES = new Set(CFG.review.harnessFiles); +const AUTH_KINDS = new Set(['guest-ok', 'auth-required', 'premium', 'UNKNOWN']); + +/** Anything that opens a socket or issues a request. Kept broad on purpose. */ +const NETWORK_RE = /\bfetch\s*\(|XMLHttpRequest|WebSocket|sendBeacon|EventSource|\baxios\b|\$\.ajax\s*\(/; + +// ---------------------------------------------------------------- helpers + +const findings = []; +const finding = (severity, code, subject, detail) => findings.push({ severity, code, subject, detail }); + +const protoCache = new Map(); +function protoLines(file) { + if (!protoCache.has(file)) { + const p = path.join(PROTO_DIR, file); + protoCache.set(file, fs.existsSync(p) ? fs.readFileSync(p, 'utf8').split('\n') : null); + } + return protoCache.get(file); +} + +/** + * A source string is one of: + * `file.html:1234` a line in the frozen bytes + * `file.html:12,34` / `12-34` several lines / a range in one file + * `flows-x.md D3` a ratified guard in a flow document + * free text (`sweep 2026-…`, `o-n1`, …) — legal, and reported as unresolvable-by-tool + * Returns {kind, ok, detail}. + */ +function resolveSource(src) { + const cites = String(src).split('·').map((s) => s.trim()).filter(Boolean); + let sawResolvable = false; + for (const cite of cites) { + const m = cite.match(/^([a-z0-9_-]+\.html):([0-9,\-\s]+)/i); + if (m) { + sawResolvable = true; + const lines = protoLines(m[1]); + if (!lines) return { ok: false, detail: `cited file \`${m[1]}\` is not in artifacts/prototype/` }; + for (const part of m[2].split(',')) { + for (const n of part.split('-')) { + const num = parseInt(n.trim(), 10); + if (!Number.isFinite(num)) continue; + if (num < 1 || num > lines.length) + return { ok: false, detail: `\`${m[1]}:${num}\` is past the end of the file (${lines.length} lines)` }; + if (!lines[num - 1].trim()) + return { ok: false, detail: `\`${m[1]}:${num}\` is a blank line — the citation has drifted` }; + } + } + continue; + } + const d = cite.match(/^(flows[a-z0-9-]*\.md)\s+(D[0-9]+[a-z]?)/i); + if (d) { + sawResolvable = true; + const p = path.join(ARTIFACTS, d[1]); + if (!fs.existsSync(p)) return { ok: false, detail: `cited flow document \`${d[1]}\` does not exist` }; + const txt = fs.readFileSync(p, 'utf8'); + if (!new RegExp(`\\b${d[2]}\\b`).test(txt)) + return { ok: false, detail: `\`${d[1]}\` contains no guard \`${d[2]}\`` }; + } + } + return { ok: true, resolvable: sawResolvable }; +} + +function checkField(subject, name, field, opts = {}) { + if (!field || typeof field !== 'object') { + finding('blocking', 'E0-missing-field', subject, `no \`${name}\` annotation at all`); + return false; + } + const v = field.value; + if (v === undefined || v === null || String(v).trim() === '') { + finding('blocking', 'E1-blank', subject, `\`${name}\` is blank — V12 requires a value; UNKNOWN is legal, blank is not`); + return false; + } + if (!field.source || !String(field.source).trim()) { + finding('major', 'E2-uncited', subject, `\`${name}\` = \`${v}\` carries no source — an annotation that cannot be traced is an assertion`); + return false; + } + if (opts.enum && !opts.enum.has(v)) { + finding('major', 'E3-enum', subject, `\`${name}\` = \`${v}\` is outside the closed set (${[...opts.enum].join(' · ')})`); + return false; + } + const r = resolveSource(field.source); + if (!r.ok) finding('major', 'E4-evidence', subject, `\`${name}\`: ${r.detail}`); + return r.ok; +} + +// ---------------------------------------------------------------- build + +function build() { + if (!fs.existsSync(NAVGRAPH)) throw new Error(`missing ${path.relative(ROOT, NAVGRAPH)} — run navgraph.mjs first`); + if (!fs.existsSync(ANNOTATIONS)) throw new Error(`missing ${path.relative(ROOT, ANNOTATIONS)}`); + const nav = JSON.parse(fs.readFileSync(NAVGRAPH, 'utf8')); + const spec = JSON.parse(fs.readFileSync(ANNOTATIONS, 'utf8')); + + // -- A0: coverage. The edge set is navgraph's; this file may not add or drop one. + const key = (e) => `${e.from}→${e.to}${e.label ? ` (${e.label})` : ''}`; + const navKeys = nav.edges.map(key); + const annKeys = (spec.edges || []).map(key); + const seen = new Set(); + for (const k of annKeys) { + if (seen.has(k)) finding('major', 'E5-duplicate', k, 'annotated twice — one edge, one annotation'); + seen.add(k); + } + for (const k of navKeys) if (!seen.has(k)) finding('blocking', 'E6-uncovered', k, 'edge in navgraph.json with no annotation'); + const navSet = new Set(navKeys); + for (const k of annKeys) if (!navSet.has(k)) finding('major', 'E7-orphan', k, 'annotated edge that navgraph.json does not derive — fix the registry, not this file'); + + // -- per-edge fields + const byIndex = new Map(annKeys.map((k, i) => [k, spec.edges[i]])); + const edges = []; + for (const e of nav.edges) { + const a = byIndex.get(key(e)); + if (!a) continue; + const subject = key(e); + checkField(subject, 'nav', a.nav, { enum: NAV_KINDS }); + checkField(subject, 'anim', a.anim); + checkField(subject, 'api', a.api); + checkField(subject, 'guard', a.guard); + if (a.nav?.value === 'UNKNOWN') + finding('major', 'E8-no-call-site', subject, + `the registry derives this route and the frozen bytes contain no call site for it. ${a.nav.note || ''}`.trim()); + if (a.hook_only) + finding('advisory', 'E13-hook-only', subject, + `the route exists as a URL hook with no in-screen control behind it — navigable by QA, unreachable by a student. ${a.nav?.note || ''}`.trim()); + edges.push({ ...e, ...a }); + } + + // -- per-frame fields + const frames = {}; + for (const n of nav.nodes) { + const f = (spec.frames || {})[n.id]; + if (!f) { finding('blocking', 'E9-frame-uncovered', n.id, 'registry screen with no frame annotation'); continue; } + checkField(n.id, 'auth', f.auth, { enum: AUTH_KINDS }); + checkField(n.id, 'perm', f.perm); + frames[n.id] = { id: n.id, name: n.name, flow: n.flow, ...f }; + } + for (const id of Object.keys(spec.frames || {})) + if (!nav.nodes.some((n) => n.id === id)) finding('major', 'E10-frame-orphan', id, 'annotated frame that is not a registry screen'); + + // -- A5: re-run the network sweep rather than trusting the recorded `api` claim + const netHits = []; + for (const file of fs.readdirSync(PROTO_DIR).filter((f) => f.endsWith('.html'))) { + if (HARNESS_FILES.has(file)) continue; // review harness, not the app surface + (protoLines(file) || []).forEach((l, i) => { if (NETWORK_RE.test(l)) netHits.push(`${file}:${i + 1}`); }); + } + const simulated = edges.filter((e) => /^none \(simulated\)/.test(e.api?.value || '')); + if (netHits.length && simulated.length) + finding('blocking', 'E11-api-claim', 'api column', + `${simulated.length} edges claim \`none (simulated)\` and the sweep found ${netHits.length} network call site(s): ${netHits.slice(0, 5).join(', ')}`); + + // -- E12: an anim value naming an animation the cited file does not declare + for (const e of edges) { + const v = e.anim?.value || ''; + const m = v.match(/^([a-z][a-z0-9-]*)\s/i); + const src = String(e.anim?.source || '').match(/^([a-z0-9_-]+\.html):/i); + if (!m || !src || /^none/.test(v) || /^transform/.test(v) || /^UNKNOWN/.test(v)) continue; + const lines = protoLines(src[1]); + if (lines && !lines.some((l) => l.includes(`@keyframes ${m[1]}`))) + finding('major', 'E12-anim-undeclared', key(e), `\`${m[1]}\` is not declared in \`${src[1]}\``); + } + + // -- counts + const tally = (arr, f) => arr.reduce((a, x) => { const k = f(x); a[k] = (a[k] || 0) + 1; return a; }, {}); + const counts = { + edges: edges.length, + frames: Object.keys(frames).length, + nav: tally(edges, (e) => e.nav?.value), + anim: tally(edges, (e) => e.anim?.value), + api: tally(edges, (e) => e.api?.value), + auth: tally(Object.values(frames), (f) => f.auth?.value), + perm: tally(Object.values(frames), (f) => f.perm?.value), + unknown: { + nav: edges.filter((e) => e.nav?.value === 'UNKNOWN').length, + anim: edges.filter((e) => e.anim?.value === 'UNKNOWN').length, + api: edges.filter((e) => e.api?.value === 'UNKNOWN').length, + guard: edges.filter((e) => e.guard?.value === 'UNKNOWN').length, + auth: Object.values(frames).filter((f) => f.auth?.value === 'UNKNOWN').length, + perm: Object.values(frames).filter((f) => f.perm?.value === 'UNKNOWN').length, + }, + guardless: edges.filter((e) => e.guard?.value === 'none').length, + networkCallSites: netHits.length, + }; + + return { + generated_by: 'tools/annotate.mjs', + inputs: { + navgraph: path.relative(ROOT, NAVGRAPH), + annotations: path.relative(ROOT, ANNOTATIONS), + version: spec.version, + }, + rules: spec.rules || [], + counts, + edges, + frames, + findings, + }; +} + +// ---------------------------------------------------------------- report + +function report(g) { + const L = []; + const T = (o) => Object.entries(o).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + L.push('# Developer Annotations — derivation report (E6)', ''); + L.push(`_Generated by \`${g.generated_by}\` from \`${g.inputs.navgraph}\` + \`${g.inputs.annotations}\` (\`${g.inputs.version}\`)._`, ''); + L.push('## Counts', ''); + L.push('| Metric | Value |', '|---|---|'); + L.push(`| Edges annotated | ${g.counts.edges} |`); + L.push(`| Frames annotated | ${g.counts.frames} |`); + L.push(`| Edges with a guard | ${g.counts.edges - g.counts.guardless} |`); + L.push(`| \`UNKNOWN\` values | nav ${g.counts.unknown.nav} · anim ${g.counts.unknown.anim} · api ${g.counts.unknown.api} · guard ${g.counts.unknown.guard} · auth ${g.counts.unknown.auth} · perm ${g.counts.unknown.perm} |`); + L.push(`| Network call sites in the prototype set | **${g.counts.networkCallSites}** |`); + L.push(''); + L.push('## Distribution', ''); + for (const [name, dist] of [['nav', g.counts.nav], ['anim', g.counts.anim], ['api', g.counts.api], ['auth (frames)', g.counts.auth], ['perm (frames)', g.counts.perm]]) { + L.push(`**${name}**`, '', '| Value | Count |', '|---|---|'); + for (const [k, v] of T(dist)) L.push(`| \`${k}\` | ${v} |`); + L.push(''); + } + L.push('## Findings', ''); + if (!g.findings.length) L.push('None.', ''); + else { + L.push('| Severity | Code | Subject | Detail |', '|---|---|---|---|'); + for (const s of SEVERITIES) for (const f of g.findings.filter((x) => x.severity === s)) + L.push(`| ${s} | \`${f.code}\` | ${f.subject} | ${f.detail} |`); + L.push(''); + } + L.push('## Edge annotations', ''); + let flow = null; + for (const e of g.edges) { + const f = e.from.split('-')[1]; + if (f !== flow) { flow = f; L.push(`### ${flow}`, '', '| Edge | nav | anim | api | guard |', '|---|---|---|---|---|'); } + const cell = (x) => `\`${x.value}\`
      ${x.source}`; + L.push(`| ${e.from} → ${e.to}${e.label ? ` ${e.label}` : ''} | ${cell(e.nav)} | ${cell(e.anim)} | ${cell(e.api)} | ${cell(e.guard)} |`); + } + L.push(''); + L.push('## Frame annotations', ''); + L.push('| Screen | auth | perm |', '|---|---|---|'); + for (const fr of Object.values(g.frames)) + L.push(`| ${fr.id} · ${fr.name} | \`${fr.auth.value}\` | \`${fr.perm.value}\` |`); + L.push(''); + return L.join('\n'); +} + +// ---------------------------------------------------------------- main + +let g; +try { + g = build(); +} catch (e) { + console.error(`annotate: ${e.message}`); + process.exit(2); +} + +fs.writeFileSync(OUT_JSON, JSON.stringify(g, null, 2)); +fs.writeFileSync(OUT_MD, report(g)); + +const cut = SEVERITIES.indexOf(FAIL_ON); +const failing = g.findings.filter((f) => SEVERITIES.indexOf(f.severity) <= cut); + +if (!QUIET) { + const c = (n) => g.findings.filter((f) => f.severity === n).length; + console.log(`annotate: ${g.counts.edges} edges · ${g.counts.frames} frames · ${g.counts.networkCallSites} network call sites`); + console.log(`findings: ${c('blocking')} blocking / ${c('major')} major / ${c('advisory')} advisory`); + for (const f of g.findings) console.log(` [${f.severity}] ${f.code} ${f.subject} — ${f.detail}`); + console.log(`wrote ${path.relative(ROOT, OUT_JSON)} · ${path.relative(ROOT, OUT_MD)}`); +} + +process.exit(failing.length ? 1 : 0); diff --git a/design-toolkit/tools/audit.mjs b/design-toolkit/tools/audit.mjs new file mode 100644 index 0000000..29bde1a --- /dev/null +++ b/design-toolkit/tools/audit.mjs @@ -0,0 +1,332 @@ +#!/usr/bin/env node +/** + * audit.mjs — STATE 08 `SELF_AUDIT` harness. + * + * Method rules carried from skills/08, and the reason each exists: + * M1 every assertion reads COMPUTED render state, never DOM presence + * (a node can exist, lay out and accept a click while painting nothing) + * M2 screenshots are evidence, not decoration + * M3 a failing assertion is a hypothesis until the probe itself is verified + * M4 sweep the source for the classes that do not show up on one screen + * + * Nothing about a specific product lives in this file. What to drive comes from + * `reference/audit-plan.json` (or, absent that, the hooks in + * `reference/state-machines.json`); the palette, tap-target floor, viewport and + * scripts come from `toolkit.config.json`. + * + * Usage: + * node tools/audit.mjs [--root ] [--plan ] [--shots ] + * [--json ] [--quiet] + * + * Exit codes: 0 = no defects, 1 = defects, 2 = tool error. + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { launch, newPage, serve } from './cdp.mjs'; +import { loadConfig, parseArgs } from './config.mjs'; + +const { arg, has } = parseArgs(); +const CFG = loadConfig(arg('--root', null)); + +const PROTO = CFG.paths.prototype; +const SHOTS = arg('--shots', CFG.paths.shots); +const OUT = arg('--json', path.join(CFG.paths.artifacts, 'audit-data.json')); +const PLAN_FILE = arg('--plan', path.join(CFG.paths.reference, 'audit-plan.json')); +const QUIET = has('--quiet'); + +const FLOOR = CFG.audit.tapTargetFloorPx; +const ALLOW = new Set((CFG.audit.colorAllowlist || []).map((h) => h.toUpperCase())); +const SCRIPTS = CFG.product.scripts || []; +const BENIGN = CFG.audit.benignConsole || []; + +const die = (m) => { console.error(`audit: ${m}`); process.exit(2); }; + +if (!existsSync(PROTO)) die(`prototype dir not found: ${PROTO}`); +mkdirSync(SHOTS, { recursive: true }); + +// ------------------------------------------------------------------ the plan +// +// A plan is a list of URLs to drive and what each one claims to be. Deriving it +// beats maintaining it: STATE 07 B2 already requires a hook per flow state, and +// STATE 12's state-machines.json records them. The explicit file wins when present. + +function loadPlan() { + if (existsSync(PLAN_FILE)) return JSON.parse(readFileSync(PLAN_FILE, 'utf8')); + + const sm = CFG.paths.stateMachines; + if (!existsSync(sm)) { + die(`no ${path.relative(CFG.root, PLAN_FILE)} and no ${path.relative(CFG.root, sm)} — ` + + `nothing to drive. Write the plan (templates/audit-plan.json) or author the state machines first.`); + } + const spec = JSON.parse(readFileSync(sm, 'utf8')); + const screens = [], states = []; + for (const [id, m] of Object.entries(spec.screens || {})) { + for (const [state, node] of Object.entries(m.states || {})) { + if (!node.hook) continue; + const row = { id, state, url: node.hook, label: `${id}·${state}` }; + (state.startsWith('happy') ? screens : states).push(row); + } + } + return { + passes: [{ name: 'base', query: '' }], + screens, + states, + _derived: 'reference/state-machines.json hooks', + }; +} + +const plan = loadPlan(); +const passes = plan.passes?.length ? plan.passes : [{ name: 'base', query: '' }]; + +// ------------------------------------------------------------------ the probe +// +// Runs in the page. Everything it returns is computed style or measured geometry. + +const probeScreen = (opt) => { + const C = opt.contract; + const on = document.querySelector(`${C.viewSelector}.${C.activeClass}`) || + document.querySelector(C.screenSelector) || document.body; + const out = { + view: on.getAttribute?.('data-view') ?? null, + sid: on.getAttribute?.('data-sid') ?? null, + painted: false, small: [], overflow: false, spill: [], contrast: [], + script: [], contrastSkipped: 0, + }; + + const cs = getComputedStyle(on), r = on.getBoundingClientRect(); + out.painted = cs.display !== 'none' && cs.visibility === 'visible' && cs.opacity !== '0' && + r.width > opt.minW && r.height > opt.minH; + + // M1 — tap targets, measured as hit area on rendered elements only. + document.querySelectorAll('button,a,input,select,textarea,[role="switch"],[role="button"],[role="tab"],[tabindex="0"]').forEach((el) => { + const b = el.getBoundingClientRect(); + if (b.width === 0 || b.height === 0) return; + if (el.closest('[hidden]')) return; + if (getComputedStyle(el).visibility === 'hidden') return; + if (el.getAttribute('tabindex') === '-1') return; + if (b.width < opt.floor - 0.5 || b.height < opt.floor - 0.5) { + out.small.push(`${el.id || el.className || el.tagName} ${Math.round(b.width)}x${Math.round(b.height)}`); + } + }); + + // Content taller than its own box with overflow visible paints over its neighbours. + // Structural assertions never see this; a screenshot does. + on.querySelectorAll?.('[class]').forEach((el) => { + if (getComputedStyle(el).overflow !== 'visible') return; + if (el.getBoundingClientRect().height === 0) return; + if (el.scrollHeight > el.clientHeight + 2 && el.clientHeight > 0) { + out.spill.push(`${el.className} ${el.scrollHeight}>${el.clientHeight}`); + } + }); + + const sc = document.querySelector(C.screenSelector); + if (sc && sc.scrollWidth > sc.clientWidth + 1) out.overflow = true; + if (document.documentElement.scrollWidth > document.documentElement.clientWidth + 1) out.overflow = true; + + // M4 — every script must resolve on a stack that carries a face for it. CSS falls + // back PER GLYPH, so a token applied correctly over a stack with no such face is + // still a defect. + for (const s of opt.scripts) { + const re = new RegExp(`[${s.range}]`); + const walker = document.createTreeWalker(on, NodeFilter.SHOW_TEXT); + let n, seen = 0; + while ((n = walker.nextNode()) && seen < 400) { + if (!re.test(n.nodeValue || '')) continue; + const el = n.parentElement; if (!el) continue; + seen++; + const ff = getComputedStyle(el).fontFamily; + if (!new RegExp(s.fontMatch, 'i').test(ff)) { + out.script.push(`${s.name}: ${el.className || el.tagName} :: ${ff.slice(0, 60)}`); + } + } + } + + // Contrast, on COMPOSITED backgrounds. A semi-transparent layer over a colour is + // not that colour, and a gradient ancestor is not machine-readable as one — those + // are counted and left to the screenshot rather than asserted (M3). + const lum = (c) => { + const m = (c || '').match(/\d+(\.\d+)?/g); if (!m) return null; + const [r0, g0, b0] = m.slice(0, 3).map(Number).map((v) => { + v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r0 + 0.7152 * g0 + 0.0722 * b0; + }; + const parse = (c) => { const m = (c || '').match(/[\d.]+/g); return m ? { r: +m[0], g: +m[1], b: +m[2], a: m[3] === undefined ? 1 : +m[3] } : null; }; + const over = (fg, bg) => ({ r: fg.r * fg.a + bg.r * (1 - fg.a), g: fg.g * fg.a + bg.g * (1 - fg.a), b: fg.b * fg.a + bg.b * (1 - fg.a), a: 1 }); + const bgOf = (el) => { + const layers = []; let e = el; + while (e) { + const s = getComputedStyle(e); + if (/gradient/.test(s.backgroundImage)) return 'GRADIENT'; + const c = parse(s.backgroundColor); + if (c && c.a > 0) { layers.push(c); if (c.a >= 0.999) break; } + e = e.parentElement; + } + if (!layers.length) return getComputedStyle(document.body).backgroundColor; + let base = layers[layers.length - 1]; + if (base.a < 0.999) base = over(base, { r: 255, g: 255, b: 255, a: 1 }); + for (let i = layers.length - 2; i >= 0; i--) base = over(layers[i], base); + return `rgb(${Math.round(base.r)}, ${Math.round(base.g)}, ${Math.round(base.b)})`; + }; + [...on.querySelectorAll('p,b,h1,h2,h3,li,span')].slice(0, 30).forEach((el) => { + if (!el.textContent.trim()) return; + if (el.children.length) return; + const bg = bgOf(el); + if (bg === 'GRADIENT') { out.contrastSkipped++; return; } + const f = lum(getComputedStyle(el).color), g = lum(bg); + if (f == null || g == null) return; + const ratio = (Math.max(f, g) + 0.05) / (Math.min(f, g) + 0.05); + const size = parseFloat(getComputedStyle(el).fontSize); + const need = size >= 18.66 ? 3 : 4.5; + if (ratio < need) out.contrast.push(`${el.className || el.tagName} ${ratio.toFixed(2)}:1 need ${need}`); + }); + + return out; +}; + +// ------------------------------------------------------------------ M4 source sweeps +// +// Classes that are invisible on any single screen: off-palette values, duplicate +// keys in string/config objects, and network calls the docs claim do not exist. + +/** Files that live in the prototype dir but are review chrome, not product surface. + * Resolved in config.mjs (`review.harnessFiles`, always including the player), so + * this sweep and annotate.mjs's network sweep read one list. Without the + * exclusion the palette sweep reports the player's own chrome as off-palette on + * every run — 11 hexes on the first reference run, all confirmed at source, none + * of them app surface. A recurring false positive teaches a reader to stop + * reading the report (M3). */ +const HARNESS_FILES = new Set(CFG.review.harnessFiles); + +async function sweepSource() { + const { readdirSync } = await import('node:fs'); + const files = readdirSync(PROTO) + .filter((f) => /\.(html|js|css)$/.test(f)) + .filter((f) => !HARNESS_FILES.has(f)); + const findings = []; + const hexes = new Map(); + for (const f of files) { + const src = readFileSync(path.join(PROTO, f), 'utf8'); + + // Palette: strip CSS id selectors first — `#feed` is not a colour (a recorded + // false positive that cost a re-run). + const scrubbed = src.replace(/[#.][A-Za-z_][\w-]*\s*(?=[{,:.\s])/g, ' '); + for (const m of scrubbed.matchAll(/#[0-9A-Fa-f]{6}\b/g)) { + const hex = m[0].toUpperCase(); + hexes.set(hex, (hexes.get(hex) || 0) + 1); + } + + // Duplicate keys in object literals — the later definition silently wins, and + // one locale can hide the defect completely. + const keys = new Map(); + for (const m of src.matchAll(/^\s{2,}([A-Za-z_]\w*)\s*:\s*['"`]/gm)) { + keys.set(m[1], (keys.get(m[1]) || 0) + 1); + } + // Counted per file; a key legitimately repeats once per locale object, so the + // report states the count and the reviewer rules it (M6, never auto-resolved). + const dupes = [...keys].filter(([, n]) => n > (CFG.product.locales?.length || 1)); + if (dupes.length) findings.push({ file: f, kind: 'duplicate-keys', detail: dupes.map(([k, n]) => `${k}×${n}`).join(', ') }); + + if (/\b(fetch|XMLHttpRequest|WebSocket|sendBeacon|EventSource)\s*\(/.test(src)) { + findings.push({ file: f, kind: 'network-call-site', detail: 'page issues a real request — the handoff must say so' }); + } + } + const offPalette = ALLOW.size ? [...hexes].filter(([h]) => !ALLOW.has(h)) : []; + if (offPalette.length) { + findings.push({ file: '(set)', kind: 'off-palette', detail: offPalette.map(([h, n]) => `${h}×${n}`).join(', ') }); + } + return { findings, hexCount: hexes.size, files: files.length }; +} + +// ------------------------------------------------------------------ run + +const PORT = CFG.audit.servePort + 6, DBG = CFG.audit.debugPort + 4; +const srv = serve(PROTO, PORT); +await new Promise((r) => setTimeout(r, 700)); +const { proc } = await launch(DBG); + +const results = { product: CFG.product.slug, checks: [], defects: [], shots: 0, runs: 0, sweeps: null }; +const add = (id, ok, detail) => { + results.checks.push({ id, ok, detail }); + if (!ok) results.defects.push({ id, detail }); +}; + +const opt = { + floor: FLOOR, + scripts: SCRIPTS, + contract: CFG.prototype, + minW: Math.min(200, CFG.product.viewport.width * 0.5), + minH: Math.min(400, CFG.product.viewport.height * 0.45), +}; + +async function drive(url, label, shot) { + const page = await newPage(DBG); + await page.init(); + + // A failed request logs "Failed to load resource" with NO url attached, so the + // console alone cannot tell an app defect from environment noise. Capture the + // network response, which carries the url, and filter benign entries BY NAME + // (M3: the alternative is reporting a missing favicon as a product defect). + const netErrors = []; + page.on((m) => { + if (m.method !== 'Network.responseReceived') return; + const { status, url: u } = m.params.response; + if (status < 400) return; + if (!u.includes('127.0.0.1')) return; // external host — not this build + if (BENIGN.some((b) => u.includes(b))) return; + netErrors.push(`${status} ${u}`); + }); + + await page.goto(`http://127.0.0.1:${PORT}/${url.replace(/^\//, '')}`); + const r = await page.eval(probeScreen, opt); + const errs = page.consoleErrors() + .filter((e) => e && !/Failed to load resource/i.test(e)) + .filter((e) => !BENIGN.some((b) => String(e).includes(b))) + .concat(netErrors); + results.runs++; + if (shot) { await page.shot(path.join(SHOTS, `${shot}.png`)); results.shots++; } + await page.close(); + return { r, errs, label }; +} + +const rows = [...(plan.screens || []), ...(plan.states || [])]; +if (!rows.length) die('the plan drives nothing — no screens and no states'); + +for (const pass of passes) { + if (!QUIET) console.log(`— pass: ${pass.name} ${pass.query ? `(${pass.query})` : ''} —————————————`); + for (const row of rows) { + const sep = row.url.includes('?') ? '&' : '?'; + const url = pass.query ? `${row.url}${sep}${pass.query}` : row.url; + const tag = `${row.label || row.id}${pass.name === 'base' ? '' : ` [${pass.name}]`}`; + const shot = `${(row.label || row.id).toLowerCase().replace(/[^\w-]+/g, '-')}${pass.name === 'base' ? '' : `-${pass.name.replace(/\W+/g, '-')}`}`; + const { r, errs } = await drive(url, tag, shot); + + add(`${tag} paints`, r.painted, `painted=${r.painted} view=${r.view}`); + add(`${tag} no console errors`, errs.length === 0, errs.join(' | ')); + add(`${tag} ${FLOOR}px targets`, r.small.length === 0, r.small.slice(0, 4).join(', ')); + add(`${tag} no h-overflow`, !r.overflow, r.overflow ? 'scrollWidth > clientWidth' : ''); + add(`${tag} no content spill`, r.spill.length === 0, r.spill.slice(0, 3).join(' | ')); + add(`${tag} contrast`, r.contrast.length === 0, r.contrast.slice(0, 3).join(' | ')); + if (SCRIPTS.length) add(`${tag} script fonts`, r.script.length === 0, r.script.slice(0, 3).join(' | ')); + if (row.id && r.sid) add(`${tag} id matches registry`, r.sid === row.id, `page prints ${r.sid}`); + if (!QUIET) process.stdout.write(r.painted ? '.' : 'X'); + } + if (!QUIET) console.log(''); +} + +results.sweeps = await sweepSource(); +for (const f of results.sweeps.findings) add(`sweep ${f.kind} · ${f.file}`, false, f.detail); + +proc.kill(); srv.kill(); + +writeFileSync(OUT, JSON.stringify(results, null, 1)); +const pass = results.checks.length - results.defects.length; +console.log(`\n${pass} / ${results.checks.length} checks · ${results.runs} runs · ${results.shots} screenshots`); +console.log(`wrote ${path.relative(CFG.root, OUT)} · screenshots in ${path.relative(CFG.root, SHOTS)}`); +if (results.defects.length) { + console.log(`\n${results.defects.length} defect(s) — per M3, confirm each at source before writing it into audit-report.md:`); + for (const d of results.defects.slice(0, 40)) console.log(` ✗ ${d.id}${d.detail ? ` — ${d.detail}` : ''}`); +} +console.log('\nM2: the screenshots are part of this audit. Read them before writing the verdict.'); +process.exit(results.defects.length ? 1 : 0); diff --git a/design-toolkit/tools/cdp.mjs b/design-toolkit/tools/cdp.mjs new file mode 100644 index 0000000..1bec39e --- /dev/null +++ b/design-toolkit/tools/cdp.mjs @@ -0,0 +1,116 @@ +/* Minimal CDP driver — no dependencies (Node ≥22 global WebSocket/fetch). + Used by the STATE 08 audit, the STATE 12 state probe, and build-time smoke runs. + Rule from skills/08 M1: assertions read COMPUTED render state (visibility, + geometry, colour), never mere DOM presence. + + Product-specific values — browser binary and viewport — come from + toolkit.config.json, never from this file. */ +import { spawn } from 'node:child_process'; +import { loadConfig, parseArgs } from './config.mjs'; + +/* This module is a library, but it is only ever imported into a CLI process that + was handed the same argv. Reading `--root` from that argv is what makes the + flag mean the same thing here as in the tool that imported it. Without it the + viewport and the Chrome path came from cwd's config while every path came from + --root's config — two configs that agree until the day they do not, and the + symptom is an audit measuring the wrong viewport with no finding to show for + it (TK-3). */ +const CFG = loadConfig(parseArgs().root); +const CHROME = process.env.TOOLKIT_CHROME || CFG.audit.chrome; +const VP = CFG.product.viewport; + +export async function launch(port = CFG.audit.debugPort, { width = VP.width, height = VP.height } = {}) { + const proc = spawn(CHROME, [ + '--headless=new', `--remote-debugging-port=${port}`, '--disable-gpu', + '--no-first-run', '--no-default-browser-check', '--hide-scrollbars', + '--disable-features=Translate,MediaRouter', `--window-size=${width},${height}`, + `--user-data-dir=/tmp/toolkit-${CFG.product.slug}-chrome`, 'about:blank' + ], { stdio: 'ignore' }); + for (let i = 0; i < 100; i++) { + try { const r = await fetch(`http://127.0.0.1:${port}/json/version`); if (r.ok) break; } + catch { await new Promise(r => setTimeout(r, 100)); } + } + return { proc, port }; +} + +export async function newPage(port) { + const r = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' }); + const t = await r.json(); + return connect(t.webSocketDebuggerUrl, t.id, port); +} + +function connect(wsUrl, targetId, port) { + const ws = new WebSocket(wsUrl); + let id = 0; + const waiters = new Map(); + const events = []; + const listeners = []; + const ready = new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + ws.onmessage = (m) => { + const msg = JSON.parse(m.data); + if (msg.id && waiters.has(msg.id)) { + const { res, rej } = waiters.get(msg.id); waiters.delete(msg.id); + msg.error ? rej(new Error(msg.error.message)) : res(msg.result); + } else if (msg.method) { + events.push(msg); + listeners.forEach(fn => fn(msg)); + } + }; + const send = async (method, params = {}) => { + await ready; + const mid = ++id; + return new Promise((res, rej) => { waiters.set(mid, { res, rej }); ws.send(JSON.stringify({ id: mid, method, params })); }); + }; + return { + send, events, targetId, + on: (fn) => listeners.push(fn), + async init() { + await send('Page.enable'); await send('Runtime.enable'); await send('Log.enable'); + // Without this the shared user-data-dir serves a stale ds.css and the audit measures + // the PREVIOUS build — a fix can look like it did not land (probe discipline, M3). + await send('Network.enable'); await send('Network.setCacheDisabled', { cacheDisabled: true }); + await send('Emulation.setDeviceMetricsOverride', { + width: VP.width, height: VP.height, deviceScaleFactor: 2, + mobile: CFG.product.platform === 'mobile', + }); + }, + async goto(url, settle = 420) { + const loaded = new Promise(res => { + const fn = (m) => { if (m.method === 'Page.loadEventFired') res(); }; + listeners.push(fn); + }); + await send('Page.navigate', { url }); + await Promise.race([loaded, new Promise(r => setTimeout(r, 6000))]); + await new Promise(r => setTimeout(r, settle)); + }, + async eval(fn, arg) { + const expr = `(${fn.toString()})(${JSON.stringify(arg ?? null)})`; + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval error'); + return r.result.value; + }, + async shot(path) { + const r = await send('Page.captureScreenshot', { format: 'png' }); + const { writeFile } = await import('node:fs/promises'); + await writeFile(path, Buffer.from(r.data, 'base64')); + }, + consoleErrors() { + return events + .filter(e => (e.method === 'Runtime.consoleAPICalled' && e.params.type === 'error') || + (e.method === 'Runtime.exceptionThrown') || + (e.method === 'Log.entryAdded' && e.params.entry.level === 'error')) + .map(e => e.params.entry?.text || + e.params.exceptionDetails?.exception?.description || + (e.params.args || []).map(a => a.value ?? a.description).join(' ')); + }, + async close() { + try { await fetch(`http://127.0.0.1:${port}/json/close/${targetId}`); } catch {} + try { ws.close(); } catch {} + } + }; +} + +export function serve(dir, port = 8788) { + const p = spawn('python3', ['-m', 'http.server', String(port), '--bind', '127.0.0.1', '-d', dir], { stdio: 'ignore' }); + return p; +} diff --git a/design-toolkit/tools/config.mjs b/design-toolkit/tools/config.mjs new file mode 100644 index 0000000..9b2c7c8 --- /dev/null +++ b/design-toolkit/tools/config.mjs @@ -0,0 +1,159 @@ +/** + * config.mjs — the one place a tool learns where this product keeps its files. + * + * Every tool in this toolkit is product-agnostic. What differs between products + * is paths, viewport, palette and ports — all of which live in + * `toolkit.config.json` at the project root, never in a tool. + * + * Usage in a tool: + * import { loadConfig } from './config.mjs'; + * const cfg = loadConfig(argRoot); // argRoot = the tool's --root flag, or null + * cfg.paths.registry // absolute path + * cfg.product.viewport.width + * + * Resolution order for the root: --root flag → $TOOLKIT_ROOT → nearest ancestor + * of cwd containing toolkit.config.json → cwd. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const CONFIG_NAME = 'toolkit.config.json'; + +const DEFAULTS = { + product: { + name: 'Unnamed product', + slug: 'product', + platform: 'mobile', + viewport: { width: 393, height: 852 }, + locales: ['en'], + scripts: [], + }, + paths: { + artifacts: 'artifacts', + reference: 'reference', + prototype: 'artifacts/prototype', + shots: 'artifacts/shots', + state: 'state/machine_state.yaml', + registry: 'reference/screen-registry.csv', + lanes: 'reference/nav-lanes.json', + stateMachines: 'reference/state-machines.json', + vocabulary: 'reference/state-vocabulary.md', + edgeAnnotations: 'reference/edge-annotations.json', + }, + screens: { + idPattern: '^S-[A-Z0-9]+-[0-9]+[A-Z]?$', + flowSectionFormat: 'FLOW-XXX • Journey Name', + }, + review: { + port: 8765, + player: 'play.html', + liveReloadWhenState: 'USER_REVIEW', + // Files that live in the prototype dir and are review chrome, not product + // surface. `player` is always added to this set — see resolution below. + harnessFiles: ['run-local.sh', 'serve.py'], + }, + prototype: { + viewSelector: '.view', + activeClass: 'active', + screenSelector: '.screen', + sidSelector: '#sid', + minVisibleNodes: 3, + }, + audit: { + chrome: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + debugPort: 9333, + servePort: 8791, + tapTargetFloorPx: 44, + colorAllowlist: [], + colorBanned: [], + benignConsole: ['favicon.ico'], + }, + flowPages: {}, // screen-id prefix → prototype page, when it is not `.html` + designSystem: { name: '', sourceId: '' }, + figma: { fileKey: '', pages: {} }, + loops: { L_CLARIFY: 3, L_RESEARCH: 2, L_UX_EDGE: 2, L_REVISION: 3, L_AUDIT_FIX: 3 }, +}; + +/** Shallow-per-section merge: a product overrides keys, never whole sections. */ +function merge(base, over) { + const out = { ...base }; + for (const [k, v] of Object.entries(over || {})) { + out[k] = v && typeof v === 'object' && !Array.isArray(v) && typeof base[k] === 'object' + ? merge(base[k], v) + : v; + } + return out; +} + +export function findRoot(explicit = null) { + if (explicit) return path.resolve(explicit); + if (process.env.TOOLKIT_ROOT) return path.resolve(process.env.TOOLKIT_ROOT); + let dir = process.cwd(); + for (let i = 0; i < 12; i++) { + if (fs.existsSync(path.join(dir, CONFIG_NAME))) return dir; + const up = path.dirname(dir); + if (up === dir) break; + dir = up; + } + return process.cwd(); +} + +export function loadConfig(explicitRoot = null) { + const root = findRoot(explicitRoot); + const file = path.join(root, CONFIG_NAME); + let raw = {}; + if (fs.existsSync(file)) { + try { + raw = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (e) { + throw new Error(`${CONFIG_NAME} is not valid JSON: ${e.message}`); + } + } + delete raw.$schema; + const cfg = merge(DEFAULTS, raw); + + // The review player is harness chrome by definition, so it is always in the + // set — a product should not have to remember to list it. Resolved once, here, + // because two tools sweep the prototype dir for two different things and both + // must exclude the SAME files. Them disagreeing is what produced TK-1: the + // palette sweep reported the player's own colours as off-palette on every run, + // while the network sweep beside it already knew to skip the file. + cfg.review.harnessFiles = [...new Set([cfg.review.player, ...(cfg.review.harnessFiles || [])])]; + + // Resolve every path to absolute, once, here — so no tool ever joins a path itself. + const abs = {}; + for (const [k, v] of Object.entries(cfg.paths)) abs[k] = path.resolve(root, v); + + return { ...cfg, root, configFile: fs.existsSync(file) ? file : null, paths: abs, rel: cfg.paths }; +} + +/** Shared arg parsing so every tool takes the same flags. */ +export function parseArgs(argv = process.argv.slice(2)) { + const arg = (flag, dflt = null) => { + const i = argv.indexOf(flag); + return i === -1 ? dflt : argv[i + 1]; + }; + const has = (flag) => argv.includes(flag); + return { argv, arg, has, root: arg('--root', null), quiet: has('--quiet') }; +} + +export const SEVERITIES = ['blocking', 'major', 'advisory']; + +/** + * The closed state vocabulary (E5). Defined once, here, because it is enforced + * by two separate tools — `navgraph.mjs` (`N11-state-vocab`) and + * `stategraph.mjs` (`S1-vocab`) — and documented in a third place, the product's + * vocabulary file (`paths.vocabulary`). Three copies of a set is three chances + * for two of them to disagree, and a vocabulary that disagrees with itself is + * not a closed set. + * + * It is a toolkit constant, not a product setting. A per-product term set would + * make every product's state machine private again, which is the failure E5 + * exists to prevent. Adding a term costs a justification written into the + * product's vocabulary file AND an edit here — deliberately two steps. + */ +export const CANON_STATES = new Set([ + 'happy', 'loading', 'empty', 'error', 'fail', 'success', 'in-progress', + 'timeout', 'guest', 'locked', 'confirm', 'filtered', 'offline', 'permission-denied', +]); diff --git a/design-toolkit/tools/config.schema.json b/design-toolkit/tools/config.schema.json new file mode 100644 index 0000000..e7bbe68 --- /dev/null +++ b/design-toolkit/tools/config.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "toolkit.config.json", + "description": "Everything a product owns. No tool in this toolkit contains a product-specific value; they all read this file via tools/config.mjs.", + "type": "object", + "properties": { + "product": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "slug": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "platform": { "enum": ["mobile", "desktop", "tablet", "responsive"] }, + "viewport": { + "type": "object", + "properties": { "width": { "type": "number" }, "height": { "type": "number" } }, + "required": ["width", "height"] + }, + "locales": { "type": "array", "items": { "type": "string" } }, + "scripts": { + "type": "array", + "description": "Every writing system the product renders. STATE 08 proves each resolves on a stack carrying a face for it — CSS falls back per glyph.", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "range": { "type": "string", "description": "character-class body, e.g. \\u1780-\\u17FF" }, + "fontMatch": { "type": "string", "description": "regex the computed font-family must match" } + }, + "required": ["name", "range", "fontMatch"] + } + } + }, + "required": ["slug", "viewport"] + }, + "paths": { + "type": "object", + "description": "All relative to the repo root. Resolved to absolute once, in config.mjs.", + "properties": { + "artifacts": { "type": "string" }, + "reference": { "type": "string" }, + "prototype": { "type": "string" }, + "shots": { "type": "string" }, + "state": { "type": "string" }, + "registry": { "type": "string" }, + "lanes": { "type": "string" }, + "stateMachines": { "type": "string" }, + "vocabulary": { "type": "string" }, + "edgeAnnotations": { "type": "string" } + } + }, + "screens": { + "type": "object", + "properties": { + "idPattern": { "type": "string" }, + "flowSectionFormat": { "type": "string" } + } + }, + "review": { + "type": "object", + "properties": { + "port": { "type": "number" }, + "player": { "type": "string" }, + "liveReloadWhenState": { "type": "string" }, + "harnessFiles": { + "type": "array", + "items": { "type": "string" }, + "description": "Files in the prototype dir that are review chrome, not product surface. Excluded from the STATE 08 palette sweep and the STATE 12 network sweep — both read this one list. `player` is always included; do not repeat it." + } + } + }, + "audit": { + "type": "object", + "properties": { + "chrome": { "type": "string" }, + "debugPort": { "type": "number" }, + "servePort": { "type": "number" }, + "tapTargetFloorPx": { "type": "number", "description": "The number STATE 04 commits to and STATE 08 checks. Set it to what you will actually build." }, + "colorAllowlist": { "type": "array", "items": { "type": "string" } }, + "colorBanned": { "type": "array", "items": { "type": "string" } }, + "benignConsole": { "type": "array", "items": { "type": "string" }, "description": "Filter known-benign console entries BY NAME, never wholesale." } + } + }, + "designSystem": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "sourceId": { "type": "string", "description": "A DS reference without an id is unverifiable — and a plan built on the wrong design system validates perfectly against it." } + } + }, + "figma": { + "type": "object", + "properties": { + "fileKey": { "type": "string" }, + "pages": { "type": "object" } + } + }, + "loops": { + "type": "object", + "properties": { + "L_CLARIFY": { "type": "number" }, + "L_RESEARCH": { "type": "number" }, + "L_UX_EDGE": { "type": "number" }, + "L_REVISION": { "type": "number" }, + "L_AUDIT_FIX": { "type": "number" } + } + } + } +} diff --git a/design-toolkit/tools/linkcheck.mjs b/design-toolkit/tools/linkcheck.mjs new file mode 100644 index 0000000..43b3c68 --- /dev/null +++ b/design-toolkit/tools/linkcheck.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node +/** + * linkcheck.mjs — documentation integrity: every internal link resolves. + * + * Why this is a tool and not a chore. This repository's documentation is a + * navigable set, not a pile of files: a skill README points at its spec, the + * spec points back, the workflow guide points at both. A broken link in that set + * is the same class of defect as a dead deep-link hook in a prototype — the + * structure claims a destination that is not there — and the toolkit's own rule + * is that a claim nobody re-checks is a claim that rots. `M6`: record what you + * cannot verify; do not assert it. + * + * Scope, stated because a scope claim belongs inside the claim: + * - Internal links only. `http(s):` and `mailto:` targets are NOT fetched — + * network state is not a property of this repository, and a check that fails + * on someone else's outage teaches readers to ignore it. + * - Links inside fenced code blocks are skipped. They are examples, not links. + * - Anchors are resolved against the target file's headings, using GitHub's + * slug rule including its `-1`, `-2` disambiguation for repeated headings. + * + * Usage: + * node tools/linkcheck.mjs [--root ] [--fail-on ] + * [--json ] [--quiet] + * + * Findings: + * D1-missing blocking link target file or directory does not exist + * D2-anchor blocking fragment has no matching heading in the target + * D3-dir advisory link to a directory that has no README.md to render + * + * Exit codes: 0 = nothing at or above --fail-on, 1 = findings, 2 = tool error. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { loadConfig, parseArgs, SEVERITIES } from './config.mjs'; + +const { arg, has } = parseArgs(); +const QUIET = has('--quiet'); +const FAIL_ON = arg('--fail-on', 'blocking'); +const OUT_JSON = arg('--json', null); + +const die = (m) => { console.error(`linkcheck: ${m}`); process.exit(2); }; + +if (!SEVERITIES.includes(FAIL_ON)) die(`--fail-on must be one of ${SEVERITIES.join(', ')}`); + +let ROOT; +try { ROOT = loadConfig(arg('--root', null)).root; } catch (e) { die(e.message); } +if (!fs.existsSync(ROOT)) die(`root not found: ${ROOT}`); + +const SKIP_DIRS = new Set(['.git', 'node_modules', '.DS_Store']); +const rel = (p) => path.relative(ROOT, p) || '.'; + +const findings = []; +const F = (severity, code, subject, detail) => findings.push({ severity, code, subject, detail }); + +// ------------------------------------------------------------------ discovery + +function walk(dir, out = []) { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch (e) { die(`cannot read ${rel(dir)}: ${e.message}`); } + for (const e of entries) { + if (SKIP_DIRS.has(e.name)) continue; + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, out); + else if (e.name.endsWith('.md')) out.push(p); + } + return out; +} + +const files = walk(ROOT).sort(); +if (!files.length) die(`no markdown files under ${rel(ROOT)}`); + +// ------------------------------------------------------------------ parsing +// +// One pass per file yields both the heading set and the link list, because both +// need the same fenced-block state and reading the file twice invites the two +// passes to disagree about where a fence started. + +/** GitHub's heading slug: lowercase, drop everything that is not word/space/hyphen, + * trim, then replace each space with a hyphen. Repeats get `-1`, `-2`, … in + * document order. + * + * The per-character replacement is the part that is easy to get wrong, and + * getting it wrong is worse than not checking. `## 1 · High-level architecture` + * loses the `·` and keeps BOTH surrounding spaces, so the anchor is + * `#1--high-level-architecture` with two hyphens. Collapsing whitespace here + * reports 167 correct links as broken — which is exactly the recurring false + * positive that teaches a reader to stop reading the report (M3). */ +const slugify = (h) => h.toLowerCase().replace(/[^\w\s-]/g, '').trim().replace(/\s/g, '-'); + +const LINK_RE = /\[([^\]]*)\]\(([^)\s]+)\)/g; + +function parse(file) { + let src; + try { src = fs.readFileSync(file, 'utf8'); } + catch (e) { die(`cannot read ${rel(file)}: ${e.message}`); } + + const anchors = new Set(); + const seen = new Map(); + const links = []; + let fence = null; + + src.split('\n').forEach((line, i) => { + const f = /^\s*(```+|~~~+)/.exec(line); + if (f) { + if (fence === null) fence = f[1][0]; + else if (f[1][0] === fence) fence = null; + return; + } + if (fence !== null) return; + + const h = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); + if (h) { + const base = slugify(h[2]); + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + anchors.add(n === 0 ? base : `${base}-${n}`); + return; + } + + for (const m of line.matchAll(LINK_RE)) links.push({ target: m[2], line: i + 1 }); + }); + + return { anchors, links }; +} + +const parsed = new Map(files.map((f) => [f, parse(f)])); + +// ------------------------------------------------------------------ resolution + +let checked = 0; + +for (const file of files) { + const { links } = parsed.get(file); + const where = (l) => `${rel(file)}:${l.line}`; + + for (const l of links) { + if (/^(https?:|mailto:|tel:|data:)/i.test(l.target)) continue; // out of scope, by design + checked++; + + // A bare fragment resolves inside this file. + if (l.target.startsWith('#')) { + const frag = decodeURIComponent(l.target.slice(1)); + if (!parsed.get(file).anchors.has(frag)) + F('blocking', 'D2-anchor', where(l), `no heading in this file produces "#${frag}"`); + continue; + } + + const [relPath, frag] = l.target.split('#'); + const abs = path.resolve(path.dirname(file), decodeURIComponent(relPath)); + + if (!fs.existsSync(abs)) { + F('blocking', 'D1-missing', where(l), `target does not exist: ${l.target}`); + continue; + } + + if (fs.statSync(abs).isDirectory()) { + if (!fs.existsSync(path.join(abs, 'README.md'))) + F('advisory', 'D3-dir', where(l), + `${l.target} is a directory with no README.md — it renders as a file listing`); + continue; + } + + if (!frag) continue; + if (!abs.endsWith('.md')) continue; // a fragment on a non-markdown target is not ours to resolve + + const target = parsed.get(abs); + if (!target) continue; // outside the scanned tree + if (!target.anchors.has(decodeURIComponent(frag))) + F('blocking', 'D2-anchor', where(l), `${relPath} has no heading producing "#${frag}"`); + } +} + +// ------------------------------------------------------------------ report + +const order = (s) => SEVERITIES.indexOf(s); +findings.sort((a, b) => order(a.severity) - order(b.severity) || a.subject.localeCompare(b.subject)); + +const counts = Object.fromEntries(SEVERITIES.map((s) => [s, findings.filter((f) => f.severity === s).length])); + +if (!QUIET) { + for (const f of findings) console.log(`${f.severity.padEnd(8)} ${f.code.padEnd(12)} ${f.subject} — ${f.detail}`); + const broken = counts.blocking + counts.major; + console.log(`\n${checked - broken}/${checked} internal links resolve · ` + + `${counts.blocking} blocking · ${counts.major} major · ${counts.advisory} advisory ` + + `· ${files.length} files`); +} + +if (OUT_JSON) { + try { + fs.mkdirSync(path.dirname(path.resolve(OUT_JSON)), { recursive: true }); + fs.writeFileSync(OUT_JSON, JSON.stringify({ root: ROOT, files: files.length, checked, counts, findings }, null, 2)); + } catch (e) { die(`cannot write ${OUT_JSON}: ${e.message}`); } +} + +const gate = SEVERITIES.slice(0, SEVERITIES.indexOf(FAIL_ON) + 1); +process.exit(findings.some((f) => gate.includes(f.severity)) ? 1 : 0); diff --git a/design-toolkit/tools/mermaidcheck.mjs b/design-toolkit/tools/mermaidcheck.mjs new file mode 100644 index 0000000..28a1a79 --- /dev/null +++ b/design-toolkit/tools/mermaidcheck.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/** + * mermaidcheck.mjs — documentation integrity: every Mermaid block is well formed. + * + * Why this exists. The diagrams in this repository are load-bearing: the state + * machine, the artifact pipeline and the gate sequence are explained by a diagram + * first and by prose second. A block that fails to parse renders on GitHub as a + * grey error box, and the reader loses the explanation without being told they + * lost anything — a silent failure, which is the class this toolkit exists to + * make loud. + * + * Scope, stated plainly: this is a **syntax smell** check, not a Mermaid parser. + * It catches the three mistakes that actually shipped here — an unclosed fence, + * an unrecognised diagram type, and an unbalanced quote — and it flags one more + * as advisory. It cannot prove a block renders. Rendering is proved by looking at + * the page (`M2`), and this check is what stops you looking for the wrong reason. + * + * Usage: + * node tools/mermaidcheck.mjs [--root ] [--fail-on ] + * [--json ] [--quiet] + * + * Findings: + * D4-type blocking first token is not a recognised diagram type + * D5-quotes major odd number of `"` on a line — an unterminated label + * D6-unclosed blocking a ```mermaid fence that never closes + * D7-parens advisory parentheses inside an unquoted square-bracket label + * + * Exit codes: 0 = nothing at or above --fail-on, 1 = findings, 2 = tool error. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { loadConfig, parseArgs, SEVERITIES } from './config.mjs'; + +const { arg, has } = parseArgs(); +const QUIET = has('--quiet'); +const FAIL_ON = arg('--fail-on', 'blocking'); +const OUT_JSON = arg('--json', null); + +const die = (m) => { console.error(`mermaidcheck: ${m}`); process.exit(2); }; + +if (!SEVERITIES.includes(FAIL_ON)) die(`--fail-on must be one of ${SEVERITIES.join(', ')}`); + +let ROOT; +try { ROOT = loadConfig(arg('--root', null)).root; } catch (e) { die(e.message); } +if (!fs.existsSync(ROOT)) die(`root not found: ${ROOT}`); + +/** Every diagram type used or plausibly usable here. A type outside this set is + * far more often a typo than a new Mermaid feature — but when it IS a new + * feature, adding it is one line, and the finding told you to look. */ +const TYPES = new Set([ + 'flowchart', 'graph', 'stateDiagram', 'stateDiagram-v2', 'sequenceDiagram', + 'classDiagram', 'erDiagram', 'journey', 'gantt', 'pie', 'quadrantChart', + 'requirementDiagram', 'gitGraph', 'mindmap', 'timeline', 'sankey-beta', + 'xychart-beta', 'block-beta', 'packet-beta', 'architecture-beta', 'C4Context', +]); + +/** Node shapes whose own delimiters contain brackets or parentheses. These must be + * recognised BEFORE the label scan, or every cylinder `db[(Store)]` and stadium + * `s([Go])` reports as an unquoted paren. That false positive is not + * hypothetical: the throwaway version of this script produced twelve of them on + * its first run, and every one was a valid shape. A check that cries wolf gets + * skimmed, and a skimmed check is not a check (`M3`). */ +const COMPOUND_SHAPE = /^\s*[\w-]+\s*(\[\(|\(\[|\[\[|\(\(|\{\{|\[\/|\[\\|>)/; + +const SKIP_DIRS = new Set(['.git', 'node_modules']); +const rel = (p) => path.relative(ROOT, p) || '.'; + +const findings = []; +const F = (severity, code, subject, detail) => findings.push({ severity, code, subject, detail }); + +function walk(dir, out = []) { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch (e) { die(`cannot read ${rel(dir)}: ${e.message}`); } + for (const e of entries) { + if (SKIP_DIRS.has(e.name)) continue; + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, out); + else if (e.name.endsWith('.md')) out.push(p); + } + return out; +} + +const files = walk(ROOT).sort(); +let blocks = 0; + +for (const file of files) { + let lines; + try { lines = fs.readFileSync(file, 'utf8').split('\n'); } + catch (e) { die(`cannot read ${rel(file)}: ${e.message}`); } + + let open = false, startLine = 0, body = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!open && /^\s*```+\s*mermaid\s*$/.test(line)) { + open = true; startLine = i + 1; body = []; + continue; + } + if (open && /^\s*```+\s*$/.test(line)) { + open = false; blocks++; + checkBlock(file, startLine, body); + continue; + } + if (open) body.push({ text: line, line: i + 1 }); + } + + if (open) + F('blocking', 'D6-unclosed', `${rel(file)}:${startLine}`, + 'a ```mermaid fence opens here and never closes — everything below it renders as diagram source'); +} + +function checkBlock(file, startLine, body) { + const at = (n) => `${rel(file)}:${n}`; + + const first = body.find((b) => b.text.trim() && !b.text.trim().startsWith('%%')); + const type = first ? first.text.trim().split(/[\s:]+/)[0] : ''; + if (!TYPES.has(type)) + F('blocking', 'D4-type', at(first ? first.line : startLine), + `"${type || '(empty block)'}" is not a recognised diagram type`); + + for (const b of body) { + if ((b.text.match(/"/g) || []).length % 2) + F('major', 'D5-quotes', at(b.line), + `odd number of quotes — unterminated label: ${b.text.trim().slice(0, 72)}`); + + if (!/^(flowchart|graph)$/.test(type)) continue; + if (COMPOUND_SHAPE.test(b.text)) continue; + const m = /^\s*[\w-]+\[([^"\]]*)\]/.exec(b.text); + if (m && /[()]/.test(m[1])) + F('advisory', 'D7-parens', at(b.line), + `parentheses in an unquoted label — quote it: ${b.text.trim().slice(0, 72)}`); + } +} + +// ------------------------------------------------------------------ report + +const order = (s) => SEVERITIES.indexOf(s); +findings.sort((a, b) => order(a.severity) - order(b.severity) || a.subject.localeCompare(b.subject)); + +const counts = Object.fromEntries(SEVERITIES.map((s) => [s, findings.filter((f) => f.severity === s).length])); + +if (!QUIET) { + for (const f of findings) console.log(`${f.severity.padEnd(8)} ${f.code.padEnd(12)} ${f.subject} — ${f.detail}`); + console.log(`\n${blocks} mermaid blocks in ${files.length} files · ` + + `${counts.blocking} blocking · ${counts.major} major · ${counts.advisory} advisory`); +} + +if (OUT_JSON) { + try { + fs.mkdirSync(path.dirname(path.resolve(OUT_JSON)), { recursive: true }); + fs.writeFileSync(OUT_JSON, JSON.stringify({ root: ROOT, files: files.length, blocks, counts, findings }, null, 2)); + } catch (e) { die(`cannot write ${OUT_JSON}: ${e.message}`); } +} + +const gate = SEVERITIES.slice(0, SEVERITIES.indexOf(FAIL_ON) + 1); +process.exit(findings.some((f) => gate.includes(f.severity)) ? 1 : 0); diff --git a/design-toolkit/tools/navgraph.mjs b/design-toolkit/tools/navgraph.mjs new file mode 100644 index 0000000..c773fcb --- /dev/null +++ b/design-toolkit/tools/navgraph.mjs @@ -0,0 +1,482 @@ +#!/usr/bin/env node +/** + * navgraph.mjs — Navigation graph extractor + validator (STATE 12 / DWF-05). + * + * Reads the canonical screen registry (and, when present, the prototype pages + * and per-flow handoffs) and derives the machine-checkable navigation model + * that the Figma flow visualization is generated from and validated against. + * + * It is deliberately a *derivation*, not an authoring surface: every edge it + * emits is traceable to a registry cell. If the diagram and this output + * disagree, the diagram is wrong. + * + * Usage: + * node tools/navgraph.mjs [--root ] [--json ] [--md ] + * [--fail-on blocking|major|advisory] [--quiet] + * + * Exit codes: 0 = no findings at or above --fail-on, 1 = findings, 2 = tool error. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { loadConfig, parseArgs, SEVERITIES, CANON_STATES } from './config.mjs'; + +// ---------------------------------------------------------------- args + config + +const { arg, has } = parseArgs(); +const CFG = loadConfig(arg('--root', null)); + +const ROOT = CFG.root; +const REGISTRY = CFG.paths.registry; +const LANES = CFG.paths.lanes; +const PROTO_DIR = CFG.paths.prototype; +const ARTIFACTS = CFG.paths.artifacts; +const OUT_JSON = arg('--json', path.join(ARTIFACTS, 'navgraph.json')); +const OUT_MD = arg('--md', path.join(ARTIFACTS, 'navmap-report.md')); +const QUIET = has('--quiet'); + +/** Severity ladder (from config.mjs). `--fail-on` names the lowest rung that fails. */ +const FAIL_ON = arg('--fail-on', 'blocking'); + +// Tokens that are legitimately not screens. Anything else that fails to parse +// is reported, never silently dropped. +const EXTERNALS = new Set([ + 'app launch', 'app-launch', 'cold launch', 'exit', 'app exit', 'os back', + 'deep link', 'push notification', 'external', 'browser', 'system', +]); + +// A screen that terminates on purpose declares it here, with the reason. +// Absence from this map is what turns a terminal into a finding. +const TERMINAL_JUSTIFICATIONS = {}; + +// ---------------------------------------------------------------- csv + +function parseCSV(text) { + const rows = []; + let row = [], field = '', q = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (q) { + if (c === '"') { + if (text[i + 1] === '"') { field += '"'; i++; } else q = false; + } else field += c; + } else if (c === '"') q = true; + else if (c === ',') { row.push(field); field = ''; } + else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; } + else if (c === '\r') { /* skip */ } + else field += c; + } + if (field.length || row.length) { row.push(field); rows.push(row); } + const head = rows.shift().map((h) => h.trim()); + return rows + .filter((r) => r.some((c) => c.trim() !== '')) + .map((r) => Object.fromEntries(head.map((h, i) => [h, (r[i] ?? '').trim()]))); +} + +// ---------------------------------------------------------------- ids + +const ID_RE = /\b(?:S-)?([A-Z]{3,8}-\d{1,2}[A-Z]?)\b/g; + +const norm = (id) => id.replace(/^S-/, '').toUpperCase(); +const full = (id) => `S-${norm(id)}`; + +/** Split a registry nav cell into tokens, then each token into ids + label. */ +function parseNavCell(cell) { + const out = { edges: [], externals: [], unparsed: [] }; + if (!cell) return out; + for (const raw of cell.split('|').map((t) => t.trim()).filter(Boolean)) { + const labelMatch = raw.match(/\(([^)]*)\)/); + const label = labelMatch ? labelMatch[1].trim() : ''; + const ids = [...raw.matchAll(ID_RE)].map((m) => m[1]); + if (ids.length) { + for (const id of ids) out.edges.push({ id: full(id), label, token: raw }); + // A token like "START-02/03 skip" carries a second id our pattern cannot + // see. Report the residue rather than pretending the token was clean. + const residue = raw.replace(ID_RE, '').replace(/\([^)]*\)/g, '').trim(); + if (/\d/.test(residue)) out.unparsed.push(raw); + } else if (EXTERNALS.has(raw.toLowerCase())) { + out.externals.push(raw); + } else { + out.unparsed.push(raw); + } + } + return out; +} + +const allStatesEarly = (nodes) => { + const h = {}; + for (const n of nodes.values()) for (const s of n.states) h[s] = (h[s] || 0) + 1; + return h; +}; + +const parseStates = (cell) => + (cell || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +// ---------------------------------------------------------------- lanes + +/** + * E1 swimlanes. The registry has no actor column, so lanes come from an + * explicit override file. Anything unassigned is reported as unassigned — + * it is never guessed into a lane, because a wrong lane reads as a ruling. + */ +function loadLanes() { + if (!fs.existsSync(LANES)) return { map: {}, present: false }; + try { + const j = JSON.parse(fs.readFileSync(LANES, 'utf8')); + const map = {}; + for (const [lane, ids] of Object.entries(j.lanes || {})) + for (const id of ids) map[full(id)] = lane; + return { map, present: true, order: j.order || Object.keys(j.lanes || {}) }; + } catch (e) { + return { map: {}, present: false, error: String(e.message) }; + } +} + +// ---------------------------------------------------------------- deep links + +/** + * Which prototype page implements which screen-id prefix. + * + * Derived, not hardcoded: for prefix `ONB` the page is `onb.html` unless + * `toolkit.config.json` → `flowPages` says otherwise. A prefix with no page is + * NOT silently skipped — it becomes an N9 finding, because "this flow cannot be + * re-driven after handoff" is exactly what E4 exists to report. + */ +function flowPageMap(prefixes) { + const override = CFG.flowPages || {}; + const map = {}; + for (const prefix of prefixes) { + map[prefix] = override[prefix] || `${prefix.toLowerCase()}.html`; + } + return map; +} + +/** E4: the query-param hooks each prototype page actually reads. */ +function scanDeepLinks(prefixes) { + const hooks = {}; + if (!fs.existsSync(PROTO_DIR)) return hooks; + for (const [prefix, file] of Object.entries(flowPageMap(prefixes))) { + const p = path.join(PROTO_DIR, file); + if (!fs.existsSync(p)) continue; + const src = fs.readFileSync(p, 'utf8'); + const params = new Set( + [...src.matchAll(/\.get\(['"]([a-zA-Z_][\w]*)['"]\)/g)].map((m) => m[1]), + ); + hooks[prefix] = { page: file, params: [...params].sort() }; + } + return hooks; +} + +// ---------------------------------------------------------------- build + +function build() { + if (!fs.existsSync(REGISTRY)) { + console.error(`navgraph: registry not found at ${REGISTRY}`); + process.exit(2); + } + const rows = parseCSV(fs.readFileSync(REGISTRY, 'utf8')); + const lanes = loadLanes(); + const prefixes = [...new Set(rows.map((r) => norm(r.screen_id).split('-')[0]))]; + const hooks = scanDeepLinks(prefixes); + + const nodes = new Map(); + for (const r of rows) { + const id = full(r.screen_id); + nodes.set(id, { + id, + name: r.screen_name, + flow: r.flow, + prefix: norm(id).split('-')[0], + status: r.status, + states: parseStates(r.states), + lane: lanes.map[id] || null, + purpose: r.purpose, + raw: { entry_from: r.entry_from, navigates_to: r.navigates_to }, + out: [], in: [], + }); + } + + const edges = []; + const findings = []; + const F = (sev, code, subject, detail) => + findings.push({ severity: sev, code, subject, detail }); + + const entryPoints = new Set(); + const unparsedCells = []; + + for (const r of rows) { + const from = full(r.screen_id); + const node = nodes.get(from); + + const nav = parseNavCell(r.navigates_to); + for (const e of nav.edges) { + if (!nodes.has(e.id)) { + F('blocking', 'N1-broken-edge', `${from} → ${e.id}`, + `navigates_to names a screen that is not in the registry (token: "${e.token}")`); + continue; + } + edges.push({ from, to: e.id, label: e.label, source: 'navigates_to' }); + } + for (const x of nav.unparsed) + unparsedCells.push({ screen: from, field: 'navigates_to', token: x }); + + const ent = parseNavCell(r.entry_from); + if (ent.externals.length) entryPoints.add(from); + for (const e of ent.edges) { + if (!nodes.has(e.id)) { + F('blocking', 'N1-broken-edge', `${e.id} → ${from}`, + `entry_from names a screen that is not in the registry (token: "${e.token}")`); + continue; + } + node.declaredEntries ??= []; + node.declaredEntries.push({ id: e.id, label: e.label }); + } + for (const x of ent.unparsed) + unparsedCells.push({ screen: from, field: 'entry_from', token: x }); + } + + // dedupe edges (same from/to keeps the first label, records the rest) + const seen = new Map(); + for (const e of edges) { + const k = `${e.from}→${e.to}`; + if (!seen.has(k)) seen.set(k, { ...e, labels: e.label ? [e.label] : [] }); + else if (e.label && !seen.get(k).labels.includes(e.label)) seen.get(k).labels.push(e.label); + } + const E = [...seen.values()]; + + for (const e of E) { + nodes.get(e.from).out.push(e.to); + nodes.get(e.to).in.push(e.from); + e.crossFlow = nodes.get(e.from).flow !== nodes.get(e.to).flow; + } + + // N3 — declared entry_from with no matching navigates_to edge (and back) + for (const n of nodes.values()) { + for (const d of n.declaredEntries || []) { + if (!E.some((e) => e.from === d.id && e.to === n.id)) + F('major', 'N3-asymmetric', `${d.id} → ${n.id}`, + `${n.id}.entry_from claims ${d.id}, but ${d.id}.navigates_to does not name ${n.id}`); + } + } + for (const e of E) { + const t = nodes.get(e.to); + const declared = (t.declaredEntries || []).some((d) => d.id === e.from); + if (!declared) + F('advisory', 'N3b-backedge', `${e.from} → ${e.to}`, + `${e.from}.navigates_to names ${e.to}, but ${e.to}.entry_from does not name ${e.from}`); + } + + // N2 — reachability. An inbound-only-declared screen is reachable in the + // product and broken in the registry; that is a different defect from a + // screen nothing routes to at all, and it gets a different severity. + for (const n of nodes.values()) { + if (n.in.length || entryPoints.has(n.id)) continue; + if ((n.declaredEntries || []).length) + F('major', 'N2b-inbound-only-declared', n.id, + `reachable only via entry_from (${n.declaredEntries.map((d) => d.id).join(', ')}); ` + + 'no source screen names it in navigates_to, so the forward edge cannot be drawn'); + else + F('blocking', 'N2-orphan', n.id, + 'no inbound edge and no external entry point — screen is unreachable'); + } + + // N4 — unjustified terminals + for (const n of nodes.values()) { + if (n.out.length === 0 && !TERMINAL_JUSTIFICATIONS[n.id]) + F('major', 'N4-terminal', n.id, + 'no outbound edge and no terminal justification recorded'); + } + + // N8 — lane coverage + const unassigned = [...nodes.values()].filter((n) => !n.lane); + if (unassigned.length) + F(lanes.present ? 'major' : 'advisory', 'N8-lane', `${unassigned.length} screens`, + lanes.present + ? `no lane in reference/nav-lanes.json: ${unassigned.map((n) => n.id).join(', ')}` + : 'reference/nav-lanes.json absent — swimlane layout (E1) cannot be generated'); + + // N9 — deep-link hook coverage + for (const prefix of [...new Set([...nodes.values()].map((n) => n.prefix))].sort()) { + if (!hooks[prefix]) + F('advisory', 'N9-deeplink', prefix, + `no prototype page resolved for prefix ${prefix} — deep-link column (E4) will be empty`); + else if (!hooks[prefix].params.length) + F('major', 'N9-deeplink', prefix, + `${hooks[prefix].page} exposes no query hook at all — no screen in this flow is ` + + 'directly addressable, so its states cannot be re-driven after handoff'); + else if (!hooks[prefix].params.includes('view')) + F('advisory', 'N9-deeplink', prefix, + `${hooks[prefix].page} exposes no ?view hook — screens in this flow are not directly addressable`); + } + + // N11 — state vocabulary. E5 needs a closed set; free-text state labels + // cannot be diagrammed as a shared state machine. Syntax is `canon` or + // `canon{qualifier}`. The term set is `CANON_STATES` in config.mjs — one + // definition, because stategraph.mjs enforces the same set and the product's + // vocabulary file documents it. See reference/state-vocabulary.md for the + // original → normalized mapping. + const STATE_RE = /^([a-z-]+)(?:\{([a-z0-9-]+)\})?$/; + const offVocab = [], malformed = []; + for (const s of Object.keys(allStatesEarly(nodes))) { + const m = STATE_RE.exec(s); + if (!m) { malformed.push(s); continue; } + if (!CANON_STATES.has(m[1])) offVocab.push(s); + } + if (offVocab.length) + F('advisory', 'N11-state-vocab', `${offVocab.length} labels`, + `state labels outside the canonical set: ${offVocab.map((s) => `"${s}"`).join(', ')}`); + if (malformed.length) + F('advisory', 'N11-state-syntax', `${malformed.length} labels`, + `not \`canon\` or \`canon{qualifier}\`: ${malformed.map((s) => `"${s}"`).join(', ')}`); + + for (const u of unparsedCells) + F('advisory', 'N10-unparsed', u.screen, + `${u.field} token not fully machine-readable: "${u.token}"`); + + // E3 — heatmap (in-degree, weighted by distinct source flows) + const heat = [...nodes.values()] + .map((n) => ({ + id: n.id, name: n.name, flow: n.flow, + inDegree: n.in.length, + outDegree: n.out.length, + sourceFlows: new Set(n.in.map((i) => nodes.get(i).flow)).size, + })) + .sort((a, b) => b.inDegree - a.inDegree || b.sourceFlows - a.sourceFlows); + + // E2 — cross-feature map + const cross = E.filter((e) => e.crossFlow).map((e) => ({ + from: e.from, to: e.to, + fromFlow: nodes.get(e.from).flow, toFlow: nodes.get(e.to).flow, + labels: e.labels, + })); + + // E5 — state inventory + const stateInventory = [...nodes.values()].map((n) => ({ + id: n.id, states: n.states, count: n.states.length, + })); + const allStates = {}; + for (const n of nodes.values()) + for (const s of n.states) allStates[s] = (allStates[s] || 0) + 1; + + return { + meta: { + root: path.relative(process.cwd(), ROOT) || '.', + registry: path.relative(ROOT, REGISTRY), + screens: nodes.size, + edges: E.length, + crossFlowEdges: cross.length, + flows: [...new Set([...nodes.values()].map((n) => n.flow))].sort(), + entryPoints: [...entryPoints], + lanesFile: lanes.present, + }, + nodes: [...nodes.values()].map(({ raw, declaredEntries, ...n }) => n), + edges: E, + crossFlow: cross, + heat, + states: { perScreen: stateInventory, histogram: allStates }, + deepLinks: hooks, + findings, + }; +} + +// ---------------------------------------------------------------- report + +function report(g) { + const sev = (s) => g.findings.filter((f) => f.severity === s); + const L = []; + L.push('# Navigation Graph Report'); + L.push(''); + L.push(`_Derived from \`${g.meta.registry}\` by \`tools/navgraph.mjs\`. Every edge below`); + L.push('traces to a registry cell — this file is evidence, not authoring._'); + L.push(''); + L.push('| Metric | Value |'); + L.push('|---|---|'); + L.push(`| Screens | ${g.meta.screens} |`); + L.push(`| Flows | ${g.meta.flows.length} |`); + L.push(`| Navigation edges | ${g.meta.edges} |`); + L.push(`| Cross-feature edges | ${g.meta.crossFlowEdges} |`); + L.push(`| Entry points | ${g.meta.entryPoints.join(', ') || '—'} |`); + for (const s of SEVERITIES) + L.push(`| ${s[0].toUpperCase() + s.slice(1)} findings | ${sev(s).length} |`); + L.push(''); + + L.push('## Findings'); + L.push(''); + if (!g.findings.length) L.push('None.'); + else { + L.push('| Severity | Code | Subject | Detail |'); + L.push('|---|---|---|---|'); + for (const f of SEVERITIES) + for (const x of sev(f)) + L.push(`| ${f} | ${x.code} | \`${x.subject}\` | ${x.detail} |`); + } + L.push(''); + + L.push('## Navigation heatmap (E3)'); + L.push(''); + L.push('| Screen | Name | Flow | In | Out | Source flows |'); + L.push('|---|---|---|---|---|---|'); + for (const h of g.heat.slice(0, 15)) + L.push(`| \`${h.id}\` | ${h.name} | ${h.flow} | **${h.inDegree}** | ${h.outDegree} | ${h.sourceFlows} |`); + L.push(''); + + L.push('## Cross-feature edges (E2)'); + L.push(''); + if (!g.crossFlow.length) L.push('None.'); + else { + L.push('| From | To | From flow | To flow | Label |'); + L.push('|---|---|---|---|---|'); + for (const c of g.crossFlow) + L.push(`| \`${c.from}\` | \`${c.to}\` | ${c.fromFlow} | ${c.toFlow} | ${c.labels.join(' · ') || '—'} |`); + } + L.push(''); + + L.push('## State inventory (E5)'); + L.push(''); + L.push('| State | Screens |'); + L.push('|---|---|'); + for (const [s, n] of Object.entries(g.states.histogram).sort((a, b) => b[1] - a[1])) + L.push(`| ${s} | ${n} |`); + L.push(''); + + L.push('## Deep-link hooks (E4)'); + L.push(''); + L.push('| Flow prefix | Page | Query hooks |'); + L.push('|---|---|---|'); + for (const [p, h] of Object.entries(g.deepLinks)) + L.push(`| ${p} | \`${h.page}\` | ${h.params.map((x) => `\`?${x}\``).join(' ')} |`); + L.push(''); + + return L.join('\n'); +} + +// ---------------------------------------------------------------- main + +const g = build(); +fs.mkdirSync(path.dirname(OUT_JSON), { recursive: true }); +fs.writeFileSync(OUT_JSON, JSON.stringify(g, null, 2)); +fs.writeFileSync(OUT_MD, report(g)); + +const cut = SEVERITIES.indexOf(FAIL_ON); +if (cut === -1) { + console.error(`navgraph: --fail-on must be one of ${SEVERITIES.join('|')}`); + process.exit(2); +} +const failing = g.findings.filter((f) => SEVERITIES.indexOf(f.severity) <= cut); + +if (!QUIET) { + const c = (n) => g.findings.filter((f) => f.severity === n).length; + console.log( + `navgraph: ${g.meta.screens} screens · ${g.meta.edges} edges · ` + + `${g.meta.crossFlowEdges} cross-feature · ` + + `findings ${SEVERITIES.map((s) => `${c(s)} ${s}`).join(' / ')}`, + ); + console.log(` → ${path.relative(ROOT, OUT_JSON)}`); + console.log(` → ${path.relative(ROOT, OUT_MD)}`); + for (const f of failing.filter((f) => f.severity !== 'advisory')) + console.log(` ${f.severity.toUpperCase()} ${f.code} ${f.subject}: ${f.detail}`); +} +process.exit(failing.length ? 1 : 0); diff --git a/design-toolkit/tools/smoke.mjs b/design-toolkit/tools/smoke.mjs new file mode 100644 index 0000000..b0582ee --- /dev/null +++ b/design-toolkit/tools/smoke.mjs @@ -0,0 +1,90 @@ +/* Build-time smoke run: for each (file, view) pair — does the view actually PAINT, + are there console errors, and is any interactive target below the tap-target floor? + Run it from STATE 07 (skills/07 B8) before handing anything to the audit. + + Usage: node tools/smoke.mjs [--root ] "home:dash,stack" "auth:landing,phone" ... + Floor, ports and prototype dir come from toolkit.config.json. */ +import { launch, newPage, serve } from './cdp.mjs'; +import { loadConfig, parseArgs } from './config.mjs'; + +const { argv, arg } = parseArgs(); +const CFG = loadConfig(arg('--root', null)); +const FLOOR = CFG.audit.tapTargetFloorPx; +const BENIGN = CFG.audit.benignConsole || []; + +// Drop `--root ` before reading positional specs, so a path that happens to +// contain a colon cannot be mistaken for a `:` pair. +const rootIdx = argv.indexOf('--root'); +const positional = argv.filter((_, i) => rootIdx === -1 || (i !== rootIdx && i !== rootIdx + 1)); + +const specs = positional.filter(s => s.includes(':')).map(s => { + const [file, views] = s.split(':'); + return { file, views: views.split(',') }; +}); + +if (!specs.length) { + console.error('usage: node tools/smoke.mjs ":[,...]" ...'); + process.exit(2); +} + +const PORT = CFG.audit.servePort, DBG = CFG.audit.debugPort + 2; +const srv = serve(CFG.paths.prototype, PORT); +await new Promise(r => setTimeout(r, 700)); +const { proc } = await launch(DBG); + +let pass = 0, fail = 0; +const problems = []; + +for (const { file, views } of specs) { + for (const view of views) { + const page = await newPage(DBG); + await page.init(); + const netErrors = []; + page.on((m) => { // see tools/audit.mjs — a bare + if (m.method !== 'Network.responseReceived') return; // console error carries no url + const { status, url: u } = m.params.response; + if (status < 400 || !u.includes('127.0.0.1')) return; + if (BENIGN.some((b) => u.includes(b))) return; + netErrors.push(`${status} ${u}`); + }); + const url = `http://127.0.0.1:${PORT}/${file}.html?view=${view}`; + await page.goto(url); + const r = await page.eval(({ floor, C }) => { + const on = document.querySelector(`${C.viewSelector}.${C.activeClass}`); + const out = { view: on && on.getAttribute('data-view'), sid: on && on.getAttribute('data-sid'), painted: false, small: [], overflow: 0, text: '' }; + if (!on) return out; + const cs = getComputedStyle(on), rc = on.getBoundingClientRect(); + out.painted = cs.display !== 'none' && cs.visibility === 'visible' && rc.width > 100 && rc.height > 100; + out.text = (on.innerText || '').trim().slice(0, 60); + // Tap-target floor — measure the hit area, including any ::after expansion + // (skills/08 §B: an element box below the floor is a hypothesis, not a finding). + document.querySelectorAll('button,a,input,[role="switch"],[role="button"],[tabindex="0"]').forEach(el => { + if (!el.offsetParent && getComputedStyle(el).position !== 'fixed') return; + const b = el.getBoundingClientRect(); + if (b.width === 0 || b.height === 0) return; + if (b.width < floor - 0.5 || b.height < floor - 0.5) { + out.small.push((el.id || el.className || el.tagName) + ' ' + Math.round(b.width) + 'x' + Math.round(b.height)); + } + }); + if (document.documentElement.scrollWidth > document.documentElement.clientWidth + 1) out.overflow = 1; + return out; + }, { floor: FLOOR, C: CFG.prototype }); + const errs = page.consoleErrors() + .filter((e) => e && !/Failed to load resource/i.test(e)) + .concat(netErrors); + const ok = r.painted && r.view === view && errs.length === 0 && r.small.length === 0 && !r.overflow; + if (ok) pass++; else { + fail++; + problems.push({ file, view, ...r, errs }); + } + console.log(`${ok ? 'PASS' : 'FAIL'} ${file}:${view} ${r.sid || '-'} ${r.painted ? 'painted' : 'BLANK'}` + + (r.small.length ? ` small:${r.small.join('|')}` : '') + (r.overflow ? ' H-OVERFLOW' : '') + + (errs.length ? ` errs:${errs.join(' ~ ')}` : '')); + await page.close(); + } +} + +console.log(`\n${pass} pass / ${fail} fail`); +if (problems.length) console.log(JSON.stringify(problems, null, 1).slice(0, 4000)); +proc.kill(); srv.kill(); +process.exit(fail ? 1 : 0); diff --git a/design-toolkit/tools/stategraph.mjs b/design-toolkit/tools/stategraph.mjs new file mode 100644 index 0000000..a08f903 --- /dev/null +++ b/design-toolkit/tools/stategraph.mjs @@ -0,0 +1,391 @@ +#!/usr/bin/env node +/** + * stategraph.mjs — Per-screen state-machine extractor + validator (STATE 12 / DWF-05, E5). + * + * navgraph.mjs answers "which screen leads to which screen". This answers the + * question one level down: *within* a screen, which states exist, what moves + * the screen between them, and how does a developer or QA reach each one. + * + * Two inputs, and the split matters: + * reference/screen-registry.csv — the canonical state SET per screen (owned by the + * registry; this tool never invents a state) + * reference/state-machines.json — the transitions between those states, each one + * carrying a `evidence` file:line into the frozen + * prototype, and each state carrying the `hook` + * that drives it + * + * So the node set is derived and the edge set is authored-with-evidence. Every + * authored claim is then checked back against the bytes: a transition whose + * evidence line does not exist is a finding, and a hook whose parameter the page + * never reads is a finding. An unverifiable diagram is the thing this file exists + * to prevent. + * + * Usage: + * node tools/stategraph.mjs [--root ] [--json ] [--md ] + * [--fail-on blocking|major|advisory] [--quiet] + * + * Exit codes: 0 = no findings at or above --fail-on, 1 = findings, 2 = tool error. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { loadConfig, parseArgs, SEVERITIES, CANON_STATES } from './config.mjs'; + +// ---------------------------------------------------------------- args + config + +const { arg, has } = parseArgs(); +const CFG = loadConfig(arg('--root', null)); + +const ROOT = CFG.root; +const REGISTRY = CFG.paths.registry; +const MACHINES = CFG.paths.stateMachines; +const VOCAB = CFG.paths.vocabulary; +const PROTO_DIR = CFG.paths.prototype; +const ARTIFACTS = CFG.paths.artifacts; +const OUT_JSON = arg('--json', path.join(ARTIFACTS, 'stategraph.json')); +const OUT_MD = arg('--md', path.join(ARTIFACTS, 'statemap-report.md')); +const QUIET = has('--quiet'); + +const FAIL_ON = arg('--fail-on', 'blocking'); + +/* The term set is `CANON_STATES` in config.mjs — imported, not restated. + navgraph.mjs enforces the same set, and the product's vocabulary file + (`paths.vocabulary`) documents it; adding a term means editing that constant + and that file. */ + +/** How a transition is caused. A trigger must declare one, so the diagram can class it. */ +const TRIGGER_KINDS = new Set([ + 'user', // the student did something on this screen + 'system', // time, a simulated response, a settle + 'entry', // the state the screen is built in on arrival (guard on entry data) + 'data', // a stored/profile value the screen reads +]); + +// ---------------------------------------------------------------- csv + +function parseCSV(text) { + const rows = []; + let row = [], field = '', q = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (q) { + if (c === '"') { + if (text[i + 1] === '"') { field += '"'; i++; } else q = false; + } else field += c; + } else if (c === '"') q = true; + else if (c === ',') { row.push(field); field = ''; } + else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; } + else if (c === '\r') { /* skip */ } + else field += c; + } + if (field.length || row.length) { row.push(field); rows.push(row); } + const head = rows.shift().map((h) => h.trim()); + return rows + .filter((r) => r.some((c) => c.trim() !== '')) + .map((r) => Object.fromEntries(head.map((h, i) => [h, (r[i] ?? '').trim()]))); +} + +const parseStates = (cell) => + (cell || '').split(',').map((s) => s.trim()).filter(Boolean); + +// ---------------------------------------------------------------- sources + +const srcCache = new Map(); +function source(file) { + if (srcCache.has(file)) return srcCache.get(file); + const p = path.join(PROTO_DIR, file); + const v = fs.existsSync(p) + ? { text: fs.readFileSync(p, 'utf8'), lines: fs.readFileSync(p, 'utf8').split('\n').length } + : null; + srcCache.set(file, v); + return v; +} + +/** "home.html:1183" → {file,line} · a bare "home.html" is not enough to check. */ +function parseEvidence(ev) { + const m = String(ev || '').match(/^([\w.-]+\.html):(\d+)(?:-(\d+))?$/); + return m ? { file: m[1], from: +m[2], to: m[3] ? +m[3] : +m[2] } : null; +} + +/** "study.html?view=reward&skip=1" → the params it asks the page to read. */ +function parseHook(hook) { + const m = String(hook || '').match(/^([\w.-]+\.html)(?:\?(.*))?$/); + if (!m) return null; + const params = m[2] + ? m[2].split('&').map((kv) => kv.split('=')[0]).filter(Boolean) + : []; + return { file: m[1], params }; +} + +// ---------------------------------------------------------------- build + +function build() { + if (!fs.existsSync(REGISTRY)) throw new Error(`registry not found: ${REGISTRY}`); + if (!fs.existsSync(MACHINES)) throw new Error(`state machines not found: ${MACHINES}`); + + const rows = parseCSV(fs.readFileSync(REGISTRY, 'utf8')); + const spec = JSON.parse(fs.readFileSync(MACHINES, 'utf8')); + + const findings = []; + const F = (sev, code, subject, detail) => + findings.push({ severity: sev, code, subject, detail }); + + const registry = new Map(); + for (const r of rows) { + const id = (r.screen_id || '').trim(); + if (!id) continue; + registry.set(id, { + id, + flow: r.flow || '', + name: r.screen_name || '', + states: parseStates(r.states), + }); + } + + const machines = []; + + for (const [id, reg] of registry) { + const m = spec.screens?.[id]; + if (!m) { + F('blocking', 'S0-missing-machine', id, + `${reg.states.length} state(s) in the registry and no machine authored — E5 cannot draw this screen`); + continue; + } + + const declared = new Set(Object.keys(m.states || {})); + const regStates = new Set(reg.states); + + // S1 — the node set is the registry's, not the author's. + for (const s of regStates) + if (!declared.has(s)) + F('blocking', 'S1-node-missing', id, `registry declares '${s}'; the machine has no such node`); + for (const s of declared) + if (!regStates.has(s)) + F('blocking', 'S1-node-extra', id, `machine declares '${s}'; the registry does not — a state the registry has never seen`); + + // S1b — every node still has to be vocabulary-legal (N11 one level down). + for (const s of declared) { + const base = s.match(/^([a-z-]+)(?:\{([a-z0-9-]+)\})?$/); + if (!base) F('major', 'S1-syntax', id, `'${s}' is not or {qualifier}`); + else if (!CANON_STATES.has(base[1])) + F('major', 'S1-vocab', id, `'${s}' uses non-canon term '${base[1]}'`); + } + + // S2 — an initial state, and it must be a node. + if (!m.initial) F('blocking', 'S2-no-initial', id, 'no initial state declared'); + else if (!declared.has(m.initial)) + F('blocking', 'S2-bad-initial', id, `initial '${m.initial}' is not one of the screen's states`); + + // S3 — transitions land on declared nodes and name a legal trigger kind. + const T = m.transitions || []; + for (const t of T) { + if (!declared.has(t.from)) F('blocking', 'S3-bad-endpoint', id, `transition from unknown state '${t.from}'`); + if (!declared.has(t.to)) F('blocking', 'S3-bad-endpoint', id, `transition to unknown state '${t.to}'`); + if (!t.trigger) F('major', 'S3-no-trigger', id, `${t.from} → ${t.to} has no trigger`); + if (!TRIGGER_KINDS.has(t.kind)) + F('major', 'S3-bad-kind', id, `${t.from} → ${t.to} kind '${t.kind}' is not one of ${[...TRIGGER_KINDS].join('/')}`); + } + + // S4 — every transition's evidence resolves to a real line of the frozen bytes. + for (const t of T) { + if (!t.evidence) { F('major', 'S4-no-evidence', id, `${t.from} → ${t.to} cites no evidence`); continue; } + const e = parseEvidence(t.evidence); + if (!e) { F('major', 'S4-bad-evidence', id, `${t.from} → ${t.to} evidence '${t.evidence}' is not file.html:line`); continue; } + const src = source(e.file); + if (!src) F('major', 'S4-no-file', id, `${t.from} → ${t.to} cites ${e.file}, which is not in the prototype set`); + else if (e.to > src.lines) + F('major', 'S4-line-out-of-range', id, `${t.from} → ${t.to} cites ${t.evidence}; ${e.file} has ${src.lines} lines`); + } + + // S5 — a state that claims a hook must claim one the page actually reads. + for (const [s, node] of Object.entries(m.states || {})) { + if (!node.hook) continue; + const h = parseHook(node.hook); + if (!h) { F('major', 'S5-bad-hook', id, `'${s}' hook '${node.hook}' is not page.html?a=b`); continue; } + const src = source(h.file); + if (!src) { F('major', 'S5-no-file', id, `'${s}' hook targets ${h.file}, not in the prototype set`); continue; } + for (const p of h.params) { + if (!new RegExp(`get\\(['"]${p}['"]\\)`).test(src.text)) + F('major', 'S5-dead-hook', id, `'${s}' hook asks for ?${p}=, which ${h.file} never reads`); + } + } + + // S6 — reachability. A state nobody can get to is a claim, not a state. + const adj = new Map([...declared].map((s) => [s, []])); + for (const t of T) if (adj.has(t.from) && declared.has(t.to)) adj.get(t.from).push(t.to); + const seen = new Set([m.initial]); + const stack = [m.initial]; + while (stack.length) { + const cur = stack.pop(); + for (const nxt of adj.get(cur) || []) if (!seen.has(nxt)) { seen.add(nxt); stack.push(nxt); } + } + for (const s of declared) { + if (seen.has(s)) continue; + // A state reachable only by URL is legitimate (QA entry), but it must say so. + if (m.states[s]?.entry_only) continue; + // A state the registry declares and the bytes never render is the one case + // worth surfacing rather than silently drawing: the diagram would otherwise + // assert a screen behaviour that does not exist. + if (m.states[s]?.unimplemented) continue; + F('blocking', 'S6-unreachable', id, `'${s}' has no inbound transition and is not marked entry_only`); + } + + // S7 — a non-terminal state should have a way out, or say why it has none. + for (const s of declared) { + const out = (adj.get(s) || []).length; + if (out === 0 && !m.states[s]?.terminal && !m.states[s]?.unimplemented) + F('advisory', 'S7-dead-end', id, `'${s}' has no outbound transition and is not marked terminal`); + } + + // S9 — registry says the state exists; the prototype does not render it. + for (const [s, node] of Object.entries(m.states || {})) { + if (!node.unimplemented) continue; + if (!node.reason) + F('major', 'S9-no-reason', id, `'${s}' is marked unimplemented with no reason — the absence must be evidenced`); + else + F('advisory', 'S9-unimplemented', id, `'${s}' is declared in the registry and not rendered by the prototype — ${node.reason}`); + } + + machines.push({ + id, flow: reg.flow, name: reg.name, + initial: m.initial, + note: m.note || null, + states: Object.entries(m.states || {}).map(([s, n]) => ({ + state: s, hook: n.hook || null, note: n.note || null, + entry_only: !!n.entry_only, terminal: !!n.terminal, + unimplemented: !!n.unimplemented, reason: n.reason || null, + reachable: seen.has(s), + })), + transitions: T.map((t) => ({ ...t })), + }); + } + + // A machine authored for a screen the registry does not have. + for (const id of Object.keys(spec.screens || {})) + if (!registry.has(id)) + F('blocking', 'S0-unknown-screen', id, 'machine authored for a screen id the registry does not contain'); + + // S8 — the registry id and the id the prototype prints in its `sid()` readout + // disagree. Every state below was authored against the REGISTRY id; the drift is + // carried here rather than resolved, because renumbering is a registry decision. + for (const c of spec.id_conflicts || []) + F('advisory', 'S8-id-drift', c.screen, + `registry ${c.screen} is implemented by the '${c.prototype_view}' view, which prints '${c.prototype_sid}' — ${c.note}`); + + const stateUse = {}; + for (const m of machines) for (const s of m.states) stateUse[s.state] = (stateUse[s.state] || 0) + 1; + + return { + generated_by: 'tools/stategraph.mjs', + inputs: { + registry: path.relative(ROOT, REGISTRY), + machines: path.relative(ROOT, MACHINES), + vocabulary: fs.existsSync(VOCAB) ? path.relative(ROOT, VOCAB) : null, + }, + counts: { + screens: machines.length, + states: machines.reduce((a, m) => a + m.states.length, 0), + transitions: machines.reduce((a, m) => a + m.transitions.length, 0), + multiState: machines.filter((m) => m.states.length > 1).length, + singleState: machines.filter((m) => m.states.length === 1).length, + hooked: machines.reduce((a, m) => a + m.states.filter((s) => s.hook).length, 0), + }, + stateUse, + id_conflicts: spec.id_conflicts || [], + machines, + findings, + }; +} + +// ---------------------------------------------------------------- report + +function report(g) { + const sev = (s) => g.findings.filter((f) => f.severity === s); + const L = []; + L.push('# Screen State Machines — derivation report', ''); + L.push(`_Generated by \`${g.generated_by}\` from \`${g.inputs.registry}\` + \`${g.inputs.machines}\`._`, ''); + L.push('## Counts', ''); + L.push('| Metric | Value |', '|---|---|'); + L.push(`| Screens | ${g.counts.screens} |`); + L.push(`| States | ${g.counts.states} |`); + L.push(`| Transitions | ${g.counts.transitions} |`); + L.push(`| Multi-state screens | ${g.counts.multiState} |`); + L.push(`| Single-state screens | ${g.counts.singleState} |`); + L.push(`| States with a QA hook | ${g.counts.hooked} |`); + L.push(''); + L.push('## Findings', ''); + if (!g.findings.length) L.push('None.', ''); + else { + L.push('| Severity | Code | Screen | Detail |', '|---|---|---|---|'); + for (const s of SEVERITIES) + for (const f of sev(s)) L.push(`| ${s} | \`${f.code}\` | ${f.subject} | ${f.detail} |`); + L.push(''); + } + if (g.id_conflicts.length) { + L.push('## Registry ↔ prototype id drift', ''); + L.push('Every machine below is authored against the **registry** id. Where the prototype’s'); + L.push('own `sid()` readout prints a different id for the view that implements it, the pair is'); + L.push('recorded here rather than reconciled — renumbering is a registry decision, not this tool’s.', ''); + L.push('| Registry id | Prototype view | Prints | Note |', '|---|---|---|---|'); + for (const c of g.id_conflicts) + L.push(`| ${c.screen} | \`${c.prototype_view}\` | \`${c.prototype_sid}\` | ${c.note} |`); + L.push(''); + } + L.push('## Per-screen machines', ''); + let flow = null; + for (const m of g.machines) { + if (m.flow !== flow) { flow = m.flow; L.push(`### ${flow}`, ''); } + L.push(`**${m.id} · ${m.name}** — initial \`${m.initial}\`, ${m.states.length} state(s), ${m.transitions.length} transition(s)`, ''); + if (m.note) L.push(`_${m.note}_`, ''); + L.push('| State | Hook | Flags |', '|---|---|---|'); + for (const s of m.states) { + const flags = [ + s.entry_only ? 'entry-only' : '', + s.terminal ? 'terminal' : '', + s.unimplemented ? 'NOT IMPLEMENTED' : '', + ].filter(Boolean).join(', '); + L.push(`| \`${s.state}\` | ${s.hook ? `\`${s.hook}\`` : '—'} | ${flags || '—'} |`); + } + L.push(''); + if (m.transitions.length) { + L.push('| From | → | To | Trigger | Kind | Evidence |', '|---|---|---|---|---|---|'); + for (const t of m.transitions) + L.push(`| \`${t.from}\` | → | \`${t.to}\` | ${t.trigger} | ${t.kind} | \`${t.evidence}\` |`); + L.push(''); + } + } + L.push('## State usage across the product', ''); + L.push('| State | Screens |', '|---|---|'); + for (const [s, n] of Object.entries(g.stateUse).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))) + L.push(`| \`${s}\` | ${n} |`); + L.push(''); + return L.join('\n'); +} + +// ---------------------------------------------------------------- main + +let g; +try { + g = build(); +} catch (e) { + console.error(`stategraph: ${e.message}`); + process.exit(2); +} + +fs.writeFileSync(OUT_JSON, JSON.stringify(g, null, 2)); +fs.writeFileSync(OUT_MD, report(g)); + +const cut = SEVERITIES.indexOf(FAIL_ON); +const failing = g.findings.filter((f) => SEVERITIES.indexOf(f.severity) <= cut); + +if (!QUIET) { + const c = (n) => g.findings.filter((f) => f.severity === n).length; + console.log( + `stategraph: ${g.counts.screens} screens · ${g.counts.states} states · ` + + `${g.counts.transitions} transitions · ${g.counts.hooked} hooked`); + console.log(`findings: ${c('blocking')} blocking / ${c('major')} major / ${c('advisory')} advisory`); + for (const f of g.findings) console.log(` [${f.severity}] ${f.code} ${f.subject} — ${f.detail}`); + console.log(`wrote ${path.relative(ROOT, OUT_JSON)} · ${path.relative(ROOT, OUT_MD)}`); +} + +process.exit(failing.length ? 1 : 0); diff --git a/design-toolkit/tools/stateprobe.mjs b/design-toolkit/tools/stateprobe.mjs new file mode 100644 index 0000000..9220087 --- /dev/null +++ b/design-toolkit/tools/stateprobe.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/** + * stateprobe.mjs — drives every hook in reference/state-machines.json and checks + * that the state it claims to reach actually PAINTS (STATE 12 / E5). + * + * stategraph.mjs proves the hook parameter is read by the page. That is not the + * same claim — a hook that seeds state is not a hook that shows it, and one screen + * passed 84/84 DOM assertions while rendering nothing. So this probe reads computed + * visibility and geometry of the active view, never DOM presence. + * + * It also reads back the prototype's own screen-id readout for every load, which + * independently confirms (or refutes) the registry ↔ prototype id drift recorded + * in `id_conflicts` — the drift is measured here, not asserted from the source. + * + * The selectors it looks for are the prototype contract in toolkit.config.json → + * `prototype`. Paths, ports and the paint threshold come from the same file. + * + * Usage: node tools/stateprobe.mjs [--root ] [--port ] [--shots ] + * Exit codes: 0 = every hook painted, 1 = at least one did not, 2 = tool error. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { launch, newPage, serve } from './cdp.mjs'; +import { loadConfig, parseArgs } from './config.mjs'; + +const { arg } = parseArgs(); +const CFG = loadConfig(arg('--root', null)); + +const ROOT = CFG.root; +const MACHINES = CFG.paths.stateMachines; +const PROTO = CFG.paths.prototype; +const OUT = arg('--json', path.join(CFG.paths.artifacts, 'stateprobe.json')); +const SHOTS = arg('--shots', null); +const PORT = +arg('--port', CFG.audit.servePort); +const CDP = +arg('--cdp', CFG.audit.debugPort + 8); + +const spec = JSON.parse(fs.readFileSync(MACHINES, 'utf8')); + +// One load per distinct URL; a URL can be claimed by several states. +const byUrl = new Map(); +for (const [id, m] of Object.entries(spec.screens)) { + for (const [state, node] of Object.entries(m.states || {})) { + if (!node.hook) continue; + if (!byUrl.has(node.hook)) byUrl.set(node.hook, []); + byUrl.get(node.hook).push({ screen: id, state }); + } +} + +const isLoadingClaim = (claims) => claims.every((c) => c.state.startsWith('loading')); + +const results = []; +const srv = serve(PROTO, PORT); +await new Promise((r) => setTimeout(r, 400)); +const { proc } = await launch(CDP); + +try { + let n = 0; + for (const [hook, claims] of byUrl) { + const page = await newPage(CDP); + await page.init(); + // Network 4xx is captured with its URL, because the console message for a failed + // fetch carries none — and some of it is environment noise rather than an app + // defect: an offline webfont CDN, /favicon.ico. Counting those as failures is the + // harness being wrong about the app, which is exactly what M3 exists to prevent. + const netErrors = []; + page.on((m) => { + if (m.method !== 'Network.responseReceived') return; + const { status, url } = m.params.response; + if (status < 400) return; + if (!url.includes('127.0.0.1')) return; // external CDN — not this build + if (/\/favicon\.ico$/.test(url)) return; // never authored, never shipped + netErrors.push(`${status} ${url}`); + }); + // A loading claim has to be measured BEFORE the simulated fetch settles, or the + // probe reports the state that replaced it. + const settle = isLoadingClaim(claims) ? 120 : 480; + await page.goto(`http://127.0.0.1:${PORT}/${hook}`, settle); + + const probe = await page.eval((C) => { + const vis = (el) => { + const cs = getComputedStyle(el), r = el.getBoundingClientRect(); + return cs.visibility !== 'hidden' && cs.display !== 'none' && + +cs.opacity > 0.01 && r.width > 0 && r.height > 0; + }; + const active = [...document.querySelectorAll(C.viewSelector)].filter((v) => v.classList.contains(C.activeClass)); + const painted = active.filter(vis); + const sid = document.querySelector(C.sidSelector); + // Anything on screen at all — catches the "active but invisible" class directly. + const screenEl = document.querySelector(C.screenSelector) || document.body; + const sr = screenEl.getBoundingClientRect(); + const inkNodes = [...screenEl.querySelectorAll('*')].filter((el) => { + const r = el.getBoundingClientRect(); + return r.width > 8 && r.height > 8 && vis(el); + }).length; + const name = (v) => v.id || v.getAttribute('data-view') || v.getAttribute('data-sid') || '(unnamed)'; + return { + activeViews: active.map(name), + paintedViews: painted.map(name), + sid: sid ? sid.textContent.trim() : null, + screenBox: { w: Math.round(sr.width), h: Math.round(sr.height) }, + visibleNodes: inkNodes, + }; + }, CFG.prototype); + + // Only script-level errors count from the console — "Failed to load resource" is + // always mirrored by the network capture above, with the URL that decides it. + const errs = page.consoleErrors() + .filter((e) => e && !/Failed to load resource/i.test(e)) + .concat(netErrors); + // Keep the threshold LOW (config: prototype.minVisibleNodes). An empty state is + // sparse by design — an empty screen may paint an illustration, a headline and one + // line of copy and nothing else. A threshold tuned to a busy screen reports the + // correct rendering of an empty one as a failure. That class cost a 37-failure run + // in which every failure was the harness. + const ok = probe.paintedViews.length > 0 && + probe.visibleNodes >= CFG.prototype.minVisibleNodes && + errs.length === 0; + + if (SHOTS) await page.shot(path.join(SHOTS, `${String(++n).padStart(2, '0')}-${hook.replace(/[^\w]+/g, '_').slice(0, 60)}.png`)); + + results.push({ hook, claims, ...probe, consoleErrors: errs, ok }); + await page.close(); + } +} finally { + try { proc.kill(); } catch {} + try { srv.kill(); } catch {} +} + +// The measured id-drift: what the prototype prints vs the registry id claiming it. +const drift = []; +for (const r of results) { + if (!r.sid) continue; + const base = r.sid.split('·')[0].trim(); + for (const c of r.claims) + if (base.startsWith('S-') && base !== c.screen) + drift.push({ hook: r.hook, registry: c.screen, prints: r.sid }); +} + +const failed = results.filter((r) => !r.ok); +fs.writeFileSync(OUT, JSON.stringify({ + generated_by: 'tools/stateprobe.mjs', + counts: { + urls: results.length, + stateClaims: [...byUrl.values()].reduce((a, c) => a + c.length, 0), + painted: results.length - failed.length, + failed: failed.length, + driftObserved: drift.length, + }, + measured_id_drift: drift, + results, +}, null, 2)); + +console.log(`stateprobe: ${results.length} urls · ${results.length - failed.length} painted · ${failed.length} failed · ${drift.length} id-drift observations`); +for (const f of failed) + console.log(` [FAIL] ${f.hook} — painted=${JSON.stringify(f.paintedViews)} nodes=${f.visibleNodes} errs=${f.consoleErrors.length}`); +for (const d of [...new Map(drift.map((d) => [d.registry + d.prints, d])).values()]) + console.log(` [drift] ${d.registry} prints '${d.prints}' (${d.hook})`); +console.log(`wrote ${path.relative(ROOT, OUT)}`); + +process.exit(failed.length ? 1 : 0); diff --git a/extensions/telegram/README.md b/extensions/telegram/README.md index ad6f738..b6f770a 100644 --- a/extensions/telegram/README.md +++ b/extensions/telegram/README.md @@ -1,20 +1,41 @@ -# Extension — Telegram Plugin (v1) +# Extension — Telegram Plugin (v2) > **Module:** extensions/ — NOT core toolkit. Opt-in per project via `manifest.communication.telegram`. -> **Purpose:** monitor workflow progress and answer approval gates away from the computer. -> **Identity:** communication **adapter only**. All business logic stays in the Workflow Orchestrator. +> **Purpose:** monitor workflow progress, answer approval gates, and (opt-in) drive Claude Code remotely. +> **Identity:** two processes over one filesystem spool — a communication **adapter** and an optional **executor**. + +## Processes + +| File | Role | +|------|------| +| `telegram-plugin.mjs` | Adapter. Talks to Telegram, reads/writes the spool. Executes nothing. | +| `executor.mjs` | Executor. Reads `command` events from the spool and runs them through `claude -p`. Never talks to Telegram. | +| `config.mjs` | Shared `communication.telegram` manifest reader (one parser, both processes). | + +Neither process calls the other. The spool is the whole contract, so the executor stays optional and removable. ## Boundaries (hard) +**Adapter — `telegram-plugin.mjs`:** + | Does | Never | |------|-------| -| Receive workflow events (file spool) | Execute workflows | +| Receive workflow events (file spool) | Execute anything | | Send Telegram notifications | Update BRDs directly | | Capture approvals via inline buttons | Modify Git | | Answer `/status` | Modify Figma | -| | Replace or bypass the orchestrator | +| Spool free text as a `command` event | Spawn a process | + +**Executor — `executor.mjs`:** + +| Does | Never | +|------|-------| +| Consume `command` events | Touch `approval` events (those are the orchestrator's) | +| Run `claude -p` in the project root | Talk to the Telegram API | +| Emit `exec_*` progress to the outbox | Run for an unlisted sender | +| Hold a single-flight lock | Run two commands at once | -An approval tapped in Telegram is **recorded, not applied** — the orchestrator reads it, applies gate rules (stale-approval, scope checks), and logs S16 with `source: telegram`. The plugin cannot advance a stage. +An approval tapped in Telegram is still **recorded, not applied** — the orchestrator reads it, applies gate rules (stale-approval, scope checks), and logs S16 with `source: telegram`. Neither process can advance a stage. ## Architecture @@ -23,16 +44,16 @@ Workflow Orchestrator (Claude session) │ writes reads │ ▼ │ .toolkit/telegram/outbox/*.json .toolkit/telegram/inbox/*.json -.toolkit/telegram/status.json ▲ - │ │ writes - ▼ │ -telegram-plugin.mjs (daemon: flush outbox + long-poll getUpdates) - │ ▲ - ▼ │ +.toolkit/telegram/status.json ▲ │ + │ │ │ reads type:"command" + ▼ │ ▼ +telegram-plugin.mjs (daemon: flush outbox + long-poll getUpdates) executor.mjs + │ ▲ │ (daemon: claim → claude -p) + ▼ │ │ writes exec_* ──▶ outbox Telegram Bot API ──── messages / inline buttons / /status ──── ``` -Filesystem spool = the whole contract. Orchestrator never talks to Telegram; plugin never touches BRD/Git/Figma/Notion. +Filesystem spool = the whole contract. Orchestrator never talks to Telegram; adapter never spawns; executor never touches the network. ## Event Contract @@ -56,12 +77,27 @@ Only these four notification triggers exist in v1: Direction gate, Design gate, "pending_gate": "direction", "updated": "2026-07-26T08:00:00Z" } ``` -**Inbox** (plugin → orchestrator), written on button taps: +**Inbox** (adapter → orchestrator / executor): ```json { "type": "approval", "action": "approve" | "reject" | "pause" | "resume", "gate": "direction" | "design" | "final", "brd": "BRD-TP-001", "ts": "…", "source": "telegram" } +{ "type": "command", "text": "run the test suite", "from_id": 111, "ts": "…", "source": "telegram" } +{ "type": "cancel", "from_id": 111, "ts": "…", "source": "telegram" } +``` + +`approval` belongs to the orchestrator; `command` and `cancel` belong to the executor. Each consumer ignores the other's events — the executor explicitly leaves approval files in place. + +**Exec events** (executor → outbox → Telegram): + +```json +{ "type": "exec_started", "request": "run the test suite" } +{ "type": "exec_progress", "detail": "Edit b.ts" } // gated by notifications.exec_progress +{ "type": "exec_result", "detail": "2 files changed" } +{ "type": "exec_error", "detail": "Run timed out after 10 min and was terminated." } +{ "type": "exec_denied", "detail": "sender is not authorized" } +{ "type": "exec_busy", "detail": "another command is still running…" } ``` Orchestrator duty (when `telegram.enabled`): write outbox events at the four triggers; refresh `status.json` each transition; **read inbox at session entry and before every gate check** — apply as the human's gate decision, log S16 (`source: telegram`), then delete the event file. `pause` → `Blocked (paused-by-user)`; `resume` → clear it. @@ -69,16 +105,52 @@ Orchestrator duty (when `telegram.enabled`): write outbox events at the four tri ## Setup 1. @BotFather → create bot → token. `export TELEGRAM_BOT_TOKEN=…` (shell profile / `.env.local`). **Token never enters manifest or repo.** -2. Onboarding communication step (or re-run: "run onboarding communication section") fills `manifest.communication.telegram` — mode `private` | `group` | `topic` (recommended), `chat_id`, `topic_id`. +2. Onboarding **Resource Binding** stage ([project-onboarding](../../Workflows/project-onboarding.md) step 3; re-open only via "toolkit configure communication" / "toolkit onboard --update") fills `manifest.communication.telegram` — mode `private` | `group` | `topic` (recommended), `chat_id`, `topic_id`. 3. Test: `node toolkit/extensions/telegram/telegram-plugin.mjs --test` → "✅ has been successfully connected to the Dev Toolkit." 4. Run daemon: `node toolkit/extensions/telegram/telegram-plugin.mjs` (keep alive via launchd/pm2/tmux as preferred). `--once` flushes outbox without the daemon. +### Remote execution (opt-in) + +5. Add the `exec` block to `manifest.communication.telegram` — see [Architecture/project-manifest.md](../../Architecture/project-manifest.md) §2. Get your Telegram user id from `@userinfobot`; it is **not** the `chat_id`. +6. Run the second daemon alongside the first: `node toolkit/extensions/telegram/executor.mjs`. `--once` drains the inbox and exits (useful for verification). + +Chat commands once both daemons run: + +| Message | Effect | +|---------|--------| +| `/status` | Orchestrator status from `status.json` (no execution) | +| any free text | Queued as a `command` event → `claude -p`, replies stream back | +| `/new ` | Same, but starts a fresh Claude session first | +| `/cancel` | SIGTERM the running command | + +One Claude session is reused per project (id in `.toolkit/telegram/.session`) so follow-up messages keep context. + ## Security -- Token: env only. Chat allow-list: plugin ignores every chat except the configured `chat_id` (commands and callbacks both). +- Token: env only. Chat allow-list: the adapter ignores every chat except the configured `chat_id` (messages and callbacks both). - Inline-button approvals are **recorded intents**, applied by the orchestrator under its gate rules — a stray tap cannot ship anything (stale-approval + scope checks still run). -- Messages contain no secrets: BRD titles, stages, links only. +- Notification messages contain no secrets: BRD titles, stages, links only. + +### Remote execution — read before enabling + +Enabling `exec` means a chat message runs code on the machine hosting the executor. Understand these properties before turning it on: + +- **Default-closed on two switches.** `exec.enabled: true` *and* a non-empty `exec.allowed_user_ids`. An empty allow-list authorizes nobody; a missing `exec` block refuses all free text with a message. +- **Sender identity is checked twice** — the chat must match `chat_id` (adapter) and the sender must appear in `allowed_user_ids` (executor). Both layers are enforced independently. +- **`permission_mode` defaults to `acceptEdits`** — file edits proceed unattended, other tools still gate. `--allowedTools` comes from `exec.allowed_tools`. The executor never passes `--dangerously-skip-permissions`. +- **`permission_mode: bypassPermissions` is unrestricted.** Anyone who can post in the bound chat gets arbitrary command execution as your user. Only choose it for a machine and a chat you fully control. +- **Prompts are never shell-interpolated** — the message is one argv entry to `claude`, spawned without a shell. +- **Bounded runs.** One command at a time (`.exec.lock`, reclaimed if the holder dies), `timeout_minutes` SIGTERM (default 10), `/cancel` to stop early. `cwd` is pinned to the project root; no `--add-dir`. +- **Residual risk:** whoever holds the bot token or joins the bound chat inherits this capability. Rotate the token via @BotFather if it leaks, and prefer a private chat over a group. + +## Tests + +``` +node --test extensions/telegram/*.test.mjs +``` + +Pure logic only (parsing, routing, authorization, locking, session lifecycle) — no network, no real child process. End-to-end behaviour is verified by running `executor.mjs --once` against a scratch project. -## v2+ Hooks (designed-for, not built) +## v3+ Hooks (designed-for, not built) Adapter seams already in place: `formatEvent()` switch → new event types (daily summary); `handleUpdate()` → new commands (`/note`, voice messages routed to inbox as `type: note`); outbox is producer-agnostic → AI chat replies later write outbox events themselves. Slack/Discord/email = sibling adapters over the same spool contract (`communication.slack` etc.) — the spool format is the stable API, adapters are disposable. diff --git a/extensions/telegram/config.mjs b/extensions/telegram/config.mjs new file mode 100644 index 0000000..f9716f0 --- /dev/null +++ b/extensions/telegram/config.mjs @@ -0,0 +1,98 @@ +/** + * Dev-toolkit Telegram extension — manifest config reader. + * + * Shared by the adapter (telegram-plugin.mjs) and the executor (executor.mjs) so + * `communication.telegram` has exactly one parser. Indentation-scoped reader for + * the known block — not a general YAML implementation. + */ + +import { readFileSync, existsSync } from "node:fs"; + +const LIST_KEYS = new Set(["allowed_user_ids", "allowed_tools"]); +const NUMBER_KEYS = new Set(["timeout_minutes"]); + +const unquote = (v) => v.replace(/^(["'])(.*)\1$/, "$2"); + +function stripComment(line) { + // Only strip a comment that follows whitespace, so "#" inside a quoted value survives. + return line.replace(/(^|\s)#.*$/, "$1").trimEnd(); +} + +function parseInlineList(raw) { + const inner = raw.slice(1, -1).trim(); + if (!inner) return []; + return inner.split(",").map((v) => unquote(v.trim())).filter(Boolean); +} + +export function readTelegramConfig(manifestPath) { + if (!existsSync(manifestPath)) { + throw new Error(`project-manifest.yaml not found at ${manifestPath} — run from the project root.`); + } + const lines = readFileSync(manifestPath, "utf8").split("\n"); + + const cfg = {}; + const exec = {}; + let inComm = false, inTg = false, sub = null, listKey = null; + + for (const raw of lines) { + const line = stripComment(raw); + if (!line.trim()) continue; + const indent = line.length - line.trimStart().length; + const trimmed = line.trim(); + + // Continuation of a block list: "- 111" + if (listKey && trimmed.startsWith("- ")) { + (sub === "exec" ? exec : cfg)[listKey].push(unquote(trimmed.slice(2).trim())); + continue; + } + listKey = null; + + const [key, ...rest] = trimmed.split(":"); + const value = unquote(rest.join(":").trim()); + + if (indent === 0) { inComm = key === "communication"; inTg = false; sub = null; continue; } + if (inComm && indent === 2) { inTg = key === "telegram"; sub = null; continue; } + if (!inTg) continue; + + if (indent === 4) { + sub = value === "" && (key === "notifications" || key === "exec") ? key : null; + if (sub === "notifications") cfg.notifications ??= {}; + if (sub) continue; + cfg[key] = value; + continue; + } + if (indent === 6 && sub === "notifications") { cfg.notifications[key] = value === "true"; continue; } + if (indent === 6 && sub === "exec") { + if (LIST_KEYS.has(key)) { + exec[key] = value.startsWith("[") ? parseInlineList(value) : []; + if (!value.startsWith("[")) listKey = key; + continue; + } + exec[key] = NUMBER_KEYS.has(key) ? Number(value) : value === "true" ? true : value === "false" ? false : value; + } + } + + if (cfg.enabled !== "true") throw new Error("communication.telegram.enabled is not true — nothing to do."); + if (!cfg.chat_id) throw new Error("communication.telegram.chat_id missing."); + + return { + projectName: readProjectName(lines), + chatId: cfg.chat_id, + topicId: cfg.mode === "topic" && cfg.topic_id ? Number(cfg.topic_id) : undefined, + notifications: cfg.notifications ?? { approvals: true, failures: true }, + exec: { ...exec, enabled: exec.enabled === true }, + }; +} + +function readProjectName(lines) { + let inProject = false; + for (const raw of lines) { + const indent = raw.length - raw.trimStart().length; + const t = raw.trim(); + if (indent === 0) inProject = t.startsWith("project:"); + else if (inProject && indent === 2 && t.startsWith("name:")) { + return unquote(stripComment(t.slice(5)).trim()) || "Project"; + } + } + return "Project"; +} diff --git a/extensions/telegram/config.test.mjs b/extensions/telegram/config.test.mjs new file mode 100644 index 0000000..0b9cf5a --- /dev/null +++ b/extensions/telegram/config.test.mjs @@ -0,0 +1,109 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { readTelegramConfig } from "./config.mjs"; + +function manifest(body) { + const file = join(mkdtempSync(join(tmpdir(), "tg-cfg-")), "project-manifest.yaml"); + writeFileSync(file, body); + return file; +} + +const BASE = `project: + name: Toolkit Pilot +communication: + telegram: + enabled: true + mode: topic + chat_id: "-1001234567890" + topic_id: 42 +`; + +test("readTelegramConfig reads routing and project name", () => { + const cfg = readTelegramConfig(manifest(BASE)); + assert.equal(cfg.projectName, "Toolkit Pilot"); + assert.equal(cfg.chatId, "-1001234567890"); + assert.equal(cfg.topicId, 42); +}); + +test("readTelegramConfig ignores topic_id unless mode is topic", () => { + const cfg = readTelegramConfig(manifest(BASE.replace("mode: topic", "mode: group"))); + assert.equal(cfg.topicId, undefined); +}); + +test("readTelegramConfig reads notification flags", () => { + const cfg = readTelegramConfig(manifest(BASE + ` notifications: + approvals: true + failures: false +`)); + assert.deepEqual(cfg.notifications, { approvals: true, failures: false }); +}); + +test("readTelegramConfig throws when telegram is not enabled", () => { + assert.throws(() => readTelegramConfig(manifest(BASE.replace("enabled: true", "enabled: false"))), /not true/i); +}); + +test("readTelegramConfig throws when the manifest is missing", () => { + assert.throws(() => readTelegramConfig(join(tmpdir(), "nope-does-not-exist.yaml")), /not found/i); +}); + +test("readTelegramConfig throws when chat_id is absent", () => { + assert.throws(() => readTelegramConfig(manifest(BASE.replace(/ chat_id:.*\n/, ""))), /chat_id/i); +}); + +// ---------- exec block ---------- + +test("readTelegramConfig reports exec disabled when the block is absent", () => { + assert.deepEqual(readTelegramConfig(manifest(BASE)).exec, { enabled: false }); +}); + +test("readTelegramConfig reads the exec block", () => { + const cfg = readTelegramConfig(manifest(BASE + ` exec: + enabled: true + permission_mode: acceptEdits + timeout_minutes: 5 +`)); + assert.equal(cfg.exec.enabled, true); + assert.equal(cfg.exec.permission_mode, "acceptEdits"); + assert.equal(cfg.exec.timeout_minutes, 5); +}); + +test("readTelegramConfig parses an inline allow-list", () => { + const cfg = readTelegramConfig(manifest(BASE + ` exec: + enabled: true + allowed_user_ids: [111, 222] +`)); + assert.deepEqual(cfg.exec.allowed_user_ids, ["111", "222"]); +}); + +test("readTelegramConfig parses a block allow-list", () => { + const cfg = readTelegramConfig(manifest(BASE + ` exec: + enabled: true + allowed_user_ids: + - 111 + - 222 +`)); + assert.deepEqual(cfg.exec.allowed_user_ids, ["111", "222"]); +}); + +test("readTelegramConfig parses a quoted allowed_tools list", () => { + const cfg = readTelegramConfig(manifest(BASE + ` exec: + enabled: true + allowed_tools: ["Read", "Bash(git *)"] +`)); + assert.deepEqual(cfg.exec.allowed_tools, ["Read", "Bash(git *)"]); +}); + +test("readTelegramConfig defaults exec.enabled to false when unset inside the block", () => { + const cfg = readTelegramConfig(manifest(BASE + ` exec: + permission_mode: plan +`)); + assert.equal(cfg.exec.enabled, false); +}); + +test("readTelegramConfig ignores a trailing comment on a value", () => { + const cfg = readTelegramConfig(manifest(BASE.replace('chat_id: "-1001234567890"', 'chat_id: "-100123" # the group'))); + assert.equal(cfg.chatId, "-100123"); +}); diff --git a/extensions/telegram/executor.mjs b/extensions/telegram/executor.mjs new file mode 100644 index 0000000..1e288fb --- /dev/null +++ b/extensions/telegram/executor.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node +/** + * Dev-toolkit Telegram extension — inbox executor + * + * Consumes `.toolkit/telegram/inbox/*.json` command events and runs them through + * Claude Code headless (`claude -p`), streaming progress back as outbox events. + * It never talks to Telegram: the filesystem spool remains the whole contract. + * + * inbox/*.json {type:"command"} ──▶ claude -p ──▶ outbox/*.json {type:"exec_*"} + * + * Disabled unless `communication.telegram.exec.enabled: true` in project-manifest.yaml. + */ + +import { basename, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readTelegramConfig } from "./config.mjs"; +import { readFileSync, writeFileSync, rmSync, readdirSync, mkdirSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; + +// ---------- telegram formatting ---------- + +/** Split text into Telegram-sendable chunks, preferring newline boundaries. */ +export function chunkForTelegram(text, limit = 4096) { + const body = String(text ?? "").trim(); + if (!body) return []; + const chunks = []; + let rest = body; + while (rest.length > limit) { + const window = rest.slice(0, limit); + const nl = window.lastIndexOf("\n"); + const cut = nl > limit * 0.5 ? nl : limit; + chunks.push(rest.slice(0, cut).trimEnd()); + rest = rest.slice(nl > limit * 0.5 ? cut + 1 : cut); + } + if (rest.length) chunks.push(rest); + return chunks; +} + +// ---------- stream-json parsing ---------- + +/** Reduce one `--output-format stream-json` line to the progress we relay, or null. */ +export function parseStreamLine(line) { + let ev; + try { ev = JSON.parse(line); } catch { return null; } + if (ev?.type === "system" && ev.subtype === "init" && ev.session_id) { + return { kind: "session", text: ev.session_id }; + } + if (ev?.type === "result") { + return { kind: "result", text: String(ev.result ?? ""), isError: Boolean(ev.is_error) }; + } + if (ev?.type === "assistant") { + for (const block of ev.message?.content ?? []) { + if (block.type === "text" && block.text?.trim()) return { kind: "text", text: block.text }; + if (block.type === "tool_use") return { kind: "tool", text: describeTool(block) }; + } + } + return null; +} + +function describeTool(block) { + const target = block.input?.file_path ?? block.input?.path ?? block.input?.command ?? block.input?.pattern; + if (!target) return block.name; + const short = String(target).includes("/") ? basename(String(target)) : String(target); + return `${block.name} ${short}`; +} + +// ---------- authorization ---------- + +/** + * Default-closed gate for command events. Exec must be turned on explicitly and + * the sender must appear in `exec.allowed_user_ids`; an empty list allows nobody. + */ +export function isExecAuthorized(event, execCfg = {}) { + if (!execCfg.enabled) return { ok: false, reason: "exec is disabled for this project" }; + const allowed = (execCfg.allowed_user_ids ?? []).map(String); + if (!allowed.length) return { ok: false, reason: "exec allow-list is empty" }; + const from = event?.from_id; + if (from === undefined || from === null || from === "") return { ok: false, reason: "sender is not authorized" }; + if (!allowed.includes(String(from))) return { ok: false, reason: "sender is not authorized" }; + return { ok: true }; +} + +// ---------- claude invocation ---------- + +const DEFAULT_PERMISSION_MODE = "acceptEdits"; + +/** argv for `claude`. The prompt is one argv entry — never interpolated into a shell. */ +export function buildClaudeArgs({ prompt, sessionId, isNew, permissionMode, allowedTools }) { + const args = [ + "-p", String(prompt), + "--output-format", "stream-json", + "--verbose", + "--permission-mode", permissionMode || DEFAULT_PERMISSION_MODE, + ]; + args.push(isNew ? "--session-id" : "--resume", sessionId); + if (allowedTools?.length) args.push("--allowedTools", allowedTools.join(",")); + return args; +} + +// ---------- session continuity ---------- + +/** + * One Claude session per project so Telegram follow-ups keep context. + * A missing or unreadable file means "start fresh". + */ +export function loadSession(file) { + try { + const id = readFileSync(file, "utf8").trim(); + if (id) return { sessionId: id, isNew: false }; + } catch { /* fall through to a new session */ } + return { sessionId: randomUUID(), isNew: true }; +} + +export function saveSession(file, sessionId) { + writeFileSync(file, sessionId); +} + +export function resetSession(file) { + try { rmSync(file); } catch { /* already gone */ } +} + +// ---------- single-flight lock ---------- + +const isProcessAlive = (pid) => { + try { process.kill(pid, 0); return true; } catch { return false; } +}; + +/** Take the exec lock, reclaiming it if the recorded holder is corrupt or gone. */ +export function acquireLock(file, { pid = process.pid, isAlive = isProcessAlive } = {}) { + try { + const held = JSON.parse(readFileSync(file, "utf8")); + if (Number.isInteger(held?.pid) && isAlive(held.pid)) return false; + } catch { /* no lock, or an unreadable one we may reclaim */ } + writeFileSync(file, JSON.stringify({ pid, since: new Date().toISOString() })); + return true; +} + +export function releaseLock(file) { + try { rmSync(file); } catch { /* already gone */ } +} + +// ---------- inbox scanning ---------- + +/** + * Command events only. Approval events stay untouched — they belong to the + * orchestrator's gate rules, which the executor must never pre-empt. + */ +export function readInboxEvents(inboxDir, type) { + let names; + try { names = readdirSync(inboxDir); } catch { return []; } + const out = []; + for (const name of names.filter((n) => n.endsWith(".json")).sort()) { + const file = join(inboxDir, name); + try { + const event = JSON.parse(readFileSync(file, "utf8")); + if (event?.type === type) out.push({ file, event }); + } catch { /* malformed event — leave it for a human to inspect */ } + } + return out; +} + +export const readCommandEvents = (inboxDir) => readInboxEvents(inboxDir, "command"); + +// ---------- running one command ---------- + +const DEFAULT_TIMEOUT_MINUTES = 10; + +/** + * Run one command event through Claude Code headless. + * + * `spawnFn` and `emit` are injected so the whole path is testable without a real + * child process or a real Telegram round-trip. Every exit path releases the lock. + */ +export async function runCommand({ event, paths, execCfg = {}, spawnFn = spawn, emit, signal }) { + const auth = isExecAuthorized(event, execCfg); + if (!auth.ok) { + emit({ type: "exec_denied", detail: auth.reason, request: event.text }); + return; + } + if (!acquireLock(paths.lockFile)) { + emit({ type: "exec_busy", detail: "another command is still running — try again when it finishes.", request: event.text }); + return; + } + + const fresh = /^\/new\b/.test(String(event.text ?? "")); + const prompt = String(event.text ?? "").replace(/^\/new\b\s*/, "").trim(); + if (fresh) resetSession(paths.sessionFile); + const { sessionId, isNew } = loadSession(paths.sessionFile); + + const args = buildClaudeArgs({ + prompt, + sessionId, + isNew, + permissionMode: execCfg.permission_mode, + allowedTools: execCfg.allowed_tools, + }); + + emit({ type: "exec_started", request: prompt }); + + const timeoutMs = (execCfg.timeout_minutes || DEFAULT_TIMEOUT_MINUTES) * 60_000; + const child = spawnFn("claude", args, { cwd: paths.root, stdio: ["ignore", "pipe", "pipe"] }); + + let ending = null; + const timer = setTimeout(() => { ending ??= "timeout"; child.kill("SIGTERM"); }, timeoutMs); + const onAbort = () => { ending ??= "cancelled"; child.kill("SIGTERM"); }; + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + + let finished = false; + const stderr = []; + child.stderr?.on("data", (d) => stderr.push(String(d))); + + try { + const rl = createInterface({ input: child.stdout }); + const closed = new Promise((resolve) => child.on("close", resolve)); + + for await (const line of rl) { + const ev = parseStreamLine(line); + if (!ev) continue; + if (ev.kind === "session") { saveSession(paths.sessionFile, ev.text); continue; } + if (ev.kind === "result") { + finished = true; + emit({ type: ev.isError ? "exec_error" : "exec_result", detail: ev.text, request: prompt }); + continue; + } + emit({ type: "exec_progress", detail: ev.text, request: prompt }); + } + await closed; + + if (!finished) { + const detail = + ending === "cancelled" ? "Run cancelled — the Claude process was terminated." + : ending === "timeout" ? `Run timed out after ${execCfg.timeout_minutes || DEFAULT_TIMEOUT_MINUTES} min and was terminated.` + : `Claude exited without a result. ${stderr.join("").trim() || "No stderr output."}`; + emit({ type: "exec_error", detail, request: prompt }); + } + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + releaseLock(paths.lockFile); + } +} + + +// ---------- daemon ---------- + +const POLL_MS = 1500; + +function emitToOutbox(outboxDir, event) { + mkdirSync(outboxDir, { recursive: true }); + const name = `${Date.now()}-${event.type}-${randomUUID().slice(0, 8)}.json`; + writeFileSync(join(outboxDir, name), JSON.stringify({ ...event, ts: new Date().toISOString() }, null, 2)); +} + +async function daemon({ once = false } = {}) { + const root = process.cwd(); + const cfg = readTelegramConfig(join(root, "project-manifest.yaml")); + if (!cfg.exec.enabled) { + throw new Error("communication.telegram.exec.enabled is not true — the executor stays off."); + } + if (!(cfg.exec.allowed_user_ids ?? []).length) { + throw new Error("communication.telegram.exec.allowed_user_ids is empty — nobody may run commands."); + } + + const spool = join(root, ".toolkit", "telegram"); + const paths = { root, sessionFile: join(spool, ".session"), lockFile: join(spool, ".exec.lock") }; + const inbox = join(spool, "inbox"); + const outbox = join(spool, "outbox"); + const emit = (event) => emitToOutbox(outbox, event); + + releaseLock(paths.lockFile); // a lock left by a crashed run is ours to clear at startup + console.log(`[telegram-executor] up — root ${root}, ${cfg.exec.allowed_user_ids.length} authorized user(s), mode ${cfg.exec.permission_mode || "acceptEdits"}`); + + for (;;) { + for (const { file, event } of readCommandEvents(inbox)) { + rmSync(file, { force: true }); // claim it before running so a crash cannot loop + const ac = new AbortController(); + const watcher = setInterval(() => { + for (const c of readInboxEvents(inbox, "cancel")) { rmSync(c.file, { force: true }); ac.abort(); } + }, POLL_MS); + try { + await runCommand({ event, paths, execCfg: cfg.exec, emit, signal: ac.signal }); + } catch (err) { + emit({ type: "exec_error", detail: `Executor failure: ${err.message}`, request: event.text }); + } finally { + clearInterval(watcher); + } + } + // Drain stray cancels so they cannot abort the next command. + for (const c of readInboxEvents(inbox, "cancel")) rmSync(c.file, { force: true }); + if (once) return; + await new Promise((r) => setTimeout(r, POLL_MS)); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + if (process.argv[2] === "--help") { + console.log("telegram-executor: (no args) daemon · --once drain the inbox and exit"); + process.exit(0); + } + daemon({ once: process.argv[2] === "--once" }).catch((err) => { console.error(`[telegram-executor] ${err.message}`); process.exit(1); }); +} \ No newline at end of file diff --git a/extensions/telegram/executor.test.mjs b/extensions/telegram/executor.test.mjs new file mode 100644 index 0000000..9e0db96 --- /dev/null +++ b/extensions/telegram/executor.test.mjs @@ -0,0 +1,422 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { + chunkForTelegram, + parseStreamLine, + isExecAuthorized, + buildClaudeArgs, + loadSession, + saveSession, + resetSession, + acquireLock, + releaseLock, + readCommandEvents, + runCommand, +} from "./executor.mjs"; + +// ---------- chunkForTelegram ---------- + +test("chunkForTelegram returns a single chunk when text fits", () => { + assert.deepEqual(chunkForTelegram("hello", 100), ["hello"]); +}); + +test("chunkForTelegram splits oversized text into limit-sized chunks", () => { + const chunks = chunkForTelegram("a".repeat(250), 100); + assert.equal(chunks.length, 3); + assert.deepEqual(chunks.map((c) => c.length), [100, 100, 50]); +}); + +test("chunkForTelegram prefers splitting on a newline near the limit", () => { + const text = "x".repeat(90) + "\n" + "y".repeat(90); + const chunks = chunkForTelegram(text, 100); + assert.equal(chunks[0], "x".repeat(90)); + assert.equal(chunks[1], "y".repeat(90)); +}); + +test("chunkForTelegram drops empty input", () => { + assert.deepEqual(chunkForTelegram(" ", 100), []); +}); + +// ---------- parseStreamLine ---------- + +test("parseStreamLine extracts assistant text", () => { + const line = JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "working on it" }] }, + }); + assert.deepEqual(parseStreamLine(line), { kind: "text", text: "working on it" }); +}); + +test("parseStreamLine reports tool use by name", () => { + const line = JSON.stringify({ + type: "assistant", + message: { content: [{ type: "tool_use", name: "Edit", input: { file_path: "/a/b.ts" } }] }, + }); + assert.deepEqual(parseStreamLine(line), { kind: "tool", text: "Edit b.ts" }); +}); + +test("parseStreamLine captures the session id from the init event", () => { + const line = JSON.stringify({ type: "system", subtype: "init", session_id: "abc-123" }); + assert.deepEqual(parseStreamLine(line), { kind: "session", text: "abc-123" }); +}); + +test("parseStreamLine returns the final result", () => { + const line = JSON.stringify({ type: "result", subtype: "success", result: "all done", is_error: false }); + assert.deepEqual(parseStreamLine(line), { kind: "result", text: "all done", isError: false }); +}); + +test("parseStreamLine flags an error result", () => { + const line = JSON.stringify({ type: "result", subtype: "error_during_execution", result: "boom", is_error: true }); + assert.deepEqual(parseStreamLine(line), { kind: "result", text: "boom", isError: true }); +}); + +test("parseStreamLine ignores malformed json instead of throwing", () => { + assert.equal(parseStreamLine("{not json"), null); +}); + +test("parseStreamLine ignores unrecognised event types", () => { + assert.equal(parseStreamLine(JSON.stringify({ type: "user", message: {} })), null); +}); + +// ---------- isExecAuthorized ---------- + +const EXEC_ON = { enabled: true, allowed_user_ids: [42, 7] }; + +test("isExecAuthorized rejects when exec is disabled", () => { + const v = isExecAuthorized({ from_id: 42 }, { enabled: false, allowed_user_ids: [42] }); + assert.equal(v.ok, false); + assert.match(v.reason, /disabled/i); +}); + +test("isExecAuthorized rejects when no user ids are allow-listed", () => { + const v = isExecAuthorized({ from_id: 42 }, { enabled: true, allowed_user_ids: [] }); + assert.equal(v.ok, false); + assert.match(v.reason, /allow.?list/i); +}); + +test("isExecAuthorized rejects a sender outside the allow-list", () => { + const v = isExecAuthorized({ from_id: 99 }, EXEC_ON); + assert.equal(v.ok, false); + assert.match(v.reason, /not authorized/i); +}); + +test("isExecAuthorized rejects an event carrying no sender id", () => { + assert.equal(isExecAuthorized({}, EXEC_ON).ok, false); +}); + +test("isExecAuthorized accepts an allow-listed sender", () => { + assert.deepEqual(isExecAuthorized({ from_id: 7 }, EXEC_ON), { ok: true }); +}); + +test("isExecAuthorized compares ids as strings so yaml scalars match", () => { + assert.equal(isExecAuthorized({ from_id: "42" }, EXEC_ON).ok, true); +}); + +// ---------- buildClaudeArgs ---------- + +test("buildClaudeArgs starts a fresh session with an explicit session id", () => { + const args = buildClaudeArgs({ prompt: "hi", sessionId: "u-1", isNew: true, permissionMode: "acceptEdits" }); + assert.deepEqual(args.slice(0, 2), ["-p", "hi"]); + assert.ok(args.includes("--session-id")); + assert.equal(args[args.indexOf("--session-id") + 1], "u-1"); + assert.ok(!args.includes("--resume")); +}); + +test("buildClaudeArgs resumes an existing session", () => { + const args = buildClaudeArgs({ prompt: "hi", sessionId: "u-1", isNew: false, permissionMode: "acceptEdits" }); + assert.ok(args.includes("--resume")); + assert.equal(args[args.indexOf("--resume") + 1], "u-1"); + assert.ok(!args.includes("--session-id")); +}); + +test("buildClaudeArgs always requests parseable streaming output", () => { + const args = buildClaudeArgs({ prompt: "hi", sessionId: "u-1", isNew: true, permissionMode: "acceptEdits" }); + assert.equal(args[args.indexOf("--output-format") + 1], "stream-json"); + assert.ok(args.includes("--verbose")); +}); + +test("buildClaudeArgs passes the configured permission mode through", () => { + const args = buildClaudeArgs({ prompt: "hi", sessionId: "u-1", isNew: true, permissionMode: "plan" }); + assert.equal(args[args.indexOf("--permission-mode") + 1], "plan"); +}); + +test("buildClaudeArgs defaults to acceptEdits when no mode is configured", () => { + const args = buildClaudeArgs({ prompt: "hi", sessionId: "u-1", isNew: true }); + assert.equal(args[args.indexOf("--permission-mode") + 1], "acceptEdits"); +}); + +test("buildClaudeArgs never emits a permission-bypass flag", () => { + const args = buildClaudeArgs({ prompt: "hi", sessionId: "u-1", isNew: true, permissionMode: "bypassPermissions" }); + assert.ok(!args.some((a) => a.startsWith("--dangerously"))); + assert.ok(!args.some((a) => a.startsWith("--allow-dangerously"))); +}); + +test("buildClaudeArgs adds an allowed-tools allow-list when configured", () => { + const args = buildClaudeArgs({ prompt: "hi", sessionId: "u-1", isNew: true, allowedTools: ["Read", "Bash(git *)"] }); + assert.equal(args[args.indexOf("--allowedTools") + 1], "Read,Bash(git *)"); +}); + +test("buildClaudeArgs omits allowed-tools when the list is empty", () => { + const args = buildClaudeArgs({ prompt: "hi", sessionId: "u-1", isNew: true, allowedTools: [] }); + assert.ok(!args.includes("--allowedTools")); +}); + +test("buildClaudeArgs passes the prompt as one argv entry, never a shell string", () => { + const args = buildClaudeArgs({ prompt: "rm -rf / ; echo $HOME", sessionId: "u-1", isNew: true }); + assert.equal(args[1], "rm -rf / ; echo $HOME"); +}); + +// ---------- session file ---------- + +function tmp() { + return mkdtempSync(join(tmpdir(), "tg-exec-")); +} + +test("loadSession mints a new uuid session when no file exists", () => { + const s = loadSession(join(tmp(), ".session")); + assert.equal(s.isNew, true); + assert.match(s.sessionId, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); +}); + +test("loadSession resumes the saved session on the next call", () => { + const file = join(tmp(), ".session"); + const first = loadSession(file); + saveSession(file, first.sessionId); + const second = loadSession(file); + assert.deepEqual(second, { sessionId: first.sessionId, isNew: false }); +}); + +test("loadSession treats an unreadable session file as a new session", () => { + const file = join(tmp(), ".session"); + writeFileSync(file, ""); + assert.equal(loadSession(file).isNew, true); +}); + +test("resetSession forces the next load to start fresh", () => { + const file = join(tmp(), ".session"); + saveSession(file, "11111111-1111-4111-8111-111111111111"); + resetSession(file); + const after = loadSession(file); + assert.equal(after.isNew, true); + assert.notEqual(after.sessionId, "11111111-1111-4111-8111-111111111111"); +}); + +test("resetSession on a missing file is a no-op", () => { + assert.doesNotThrow(() => resetSession(join(tmp(), ".session"))); +}); + +// ---------- single-flight lock ---------- + +const ALIVE = () => true; +const DEAD = () => false; + +test("acquireLock succeeds when no lock is held", () => { + assert.equal(acquireLock(join(tmp(), ".lock"), { pid: 100, isAlive: ALIVE }), true); +}); + +test("acquireLock refuses while a live holder owns the lock", () => { + const file = join(tmp(), ".lock"); + acquireLock(file, { pid: 100, isAlive: ALIVE }); + assert.equal(acquireLock(file, { pid: 200, isAlive: ALIVE }), false); +}); + +test("acquireLock reclaims a lock whose holder is gone", () => { + const file = join(tmp(), ".lock"); + acquireLock(file, { pid: 100, isAlive: ALIVE }); + assert.equal(acquireLock(file, { pid: 200, isAlive: DEAD }), true); +}); + +test("acquireLock reclaims a corrupt lock file", () => { + const file = join(tmp(), ".lock"); + writeFileSync(file, "garbage"); + assert.equal(acquireLock(file, { pid: 200, isAlive: ALIVE }), true); +}); + +test("releaseLock frees the lock for the next caller", () => { + const file = join(tmp(), ".lock"); + acquireLock(file, { pid: 100, isAlive: ALIVE }); + releaseLock(file); + assert.equal(acquireLock(file, { pid: 200, isAlive: ALIVE }), true); +}); + +test("releaseLock on a missing lock is a no-op", () => { + assert.doesNotThrow(() => releaseLock(join(tmp(), ".lock"))); +}); + +// ---------- inbox scanning ---------- + +function inboxWith(files) { + const dir = tmp(); + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(dir, name), typeof body === "string" ? body : JSON.stringify(body)); + } + return dir; +} + +test("readCommandEvents returns nothing when the inbox does not exist", () => { + assert.deepEqual(readCommandEvents(join(tmp(), "absent")), []); +}); + +test("readCommandEvents leaves approval events for the orchestrator", () => { + const dir = inboxWith({ + "1-approval.json": { type: "approval", action: "approve", brd: "BRD-1" }, + "2-command.json": { type: "command", text: "run tests", from_id: 42 }, + }); + const events = readCommandEvents(dir); + assert.equal(events.length, 1); + assert.equal(events[0].event.text, "run tests"); + assert.ok(existsSync(join(dir, "1-approval.json")), "approval file must stay in the inbox"); +}); + +test("readCommandEvents returns commands oldest first", () => { + const dir = inboxWith({ + "20-command.json": { type: "command", text: "second", from_id: 42 }, + "10-command.json": { type: "command", text: "first", from_id: 42 }, + }); + assert.deepEqual(readCommandEvents(dir).map((e) => e.event.text), ["first", "second"]); +}); + +test("readCommandEvents skips unparseable files without throwing", () => { + const dir = inboxWith({ + "1-command.json": "{broken", + "2-command.json": { type: "command", text: "ok", from_id: 42 }, + }); + assert.deepEqual(readCommandEvents(dir).map((e) => e.event.text), ["ok"]); +}); + +test("readCommandEvents ignores non-json files", () => { + const dir = inboxWith({ "notes.txt": "hello", "1-command.json": { type: "command", text: "ok", from_id: 42 } }); + assert.equal(readCommandEvents(dir).length, 1); +}); + +// ---------- runCommand ---------- + +function fakeSpawn(lines, { code = 0, hang = false } = {}) { + const calls = []; + const fn = (cmd, args, opts) => { + calls.push({ cmd, args, opts }); + const child = new EventEmitter(); + child.stdout = Readable.from(hang ? [] : lines.map((l) => JSON.stringify(l) + "\n")); + child.stderr = Readable.from([]); + child.killed = false; + child.kill = () => { child.killed = true; child.emit("close", 143); }; + if (!hang) child.stdout.on("end", () => setImmediate(() => child.emit("close", code))); + return child; + }; + fn.calls = calls; + return fn; +} + +function paths() { + const dir = tmp(); + return { root: dir, sessionFile: join(dir, ".session"), lockFile: join(dir, ".lock") }; +} + +const OK_EXEC = { enabled: true, allowed_user_ids: [42] }; +const collect = () => { const out = []; return Object.assign((ev) => out.push(ev), { out }); }; + +test("runCommand refuses an unauthorized sender and never spawns", async () => { + const emit = collect(); + const spawnFn = fakeSpawn([]); + await runCommand({ event: { text: "hi", from_id: 99 }, paths: paths(), execCfg: OK_EXEC, spawnFn, emit }); + assert.equal(spawnFn.calls.length, 0); + assert.deepEqual(emit.out.map((e) => e.type), ["exec_denied"]); +}); + +test("runCommand runs claude in the project root with the prompt as argv", async () => { + const emit = collect(); + const p = paths(); + const spawnFn = fakeSpawn([{ type: "result", subtype: "success", result: "done", is_error: false }]); + await runCommand({ event: { text: "run tests", from_id: 42 }, paths: p, execCfg: OK_EXEC, spawnFn, emit }); + assert.equal(spawnFn.calls[0].cmd, "claude"); + assert.equal(spawnFn.calls[0].args[1], "run tests"); + assert.equal(spawnFn.calls[0].opts.cwd, p.root); + assert.equal(spawnFn.calls[0].opts.shell, undefined); +}); + +test("runCommand relays progress then the final result", async () => { + const emit = collect(); + const spawnFn = fakeSpawn([ + { type: "system", subtype: "init", session_id: "sess-9" }, + { type: "assistant", message: { content: [{ type: "tool_use", name: "Edit", input: { file_path: "/a/b.ts" } }] } }, + { type: "result", subtype: "success", result: "2 files changed", is_error: false }, + ]); + await runCommand({ event: { text: "go", from_id: 42 }, paths: paths(), execCfg: OK_EXEC, spawnFn, emit }); + assert.deepEqual(emit.out.map((e) => e.type), ["exec_started", "exec_progress", "exec_result"]); + assert.equal(emit.out.at(-1).detail, "2 files changed"); +}); + +test("runCommand persists the session id so the next command resumes it", async () => { + const p = paths(); + const spawnFn = fakeSpawn([ + { type: "system", subtype: "init", session_id: "sess-9" }, + { type: "result", subtype: "success", result: "ok", is_error: false }, + ]); + await runCommand({ event: { text: "go", from_id: 42 }, paths: p, execCfg: OK_EXEC, spawnFn, emit: collect() }); + assert.deepEqual(loadSession(p.sessionFile), { sessionId: "sess-9", isNew: false }); +}); + +test("runCommand reports an error result as exec_error", async () => { + const emit = collect(); + const spawnFn = fakeSpawn([{ type: "result", subtype: "error_during_execution", result: "boom", is_error: true }]); + await runCommand({ event: { text: "go", from_id: 42 }, paths: paths(), execCfg: OK_EXEC, spawnFn, emit }); + assert.equal(emit.out.at(-1).type, "exec_error"); +}); + +test("runCommand refuses to start while another run holds the lock", async () => { + const p = paths(); + acquireLock(p.lockFile, { pid: process.pid }); + const emit = collect(); + const spawnFn = fakeSpawn([]); + await runCommand({ event: { text: "go", from_id: 42 }, paths: p, execCfg: OK_EXEC, spawnFn, emit }); + assert.equal(spawnFn.calls.length, 0); + assert.equal(emit.out.at(-1).type, "exec_busy"); +}); + +test("runCommand releases the lock when the run finishes", async () => { + const p = paths(); + const spawnFn = fakeSpawn([{ type: "result", subtype: "success", result: "ok", is_error: false }]); + await runCommand({ event: { text: "go", from_id: 42 }, paths: p, execCfg: OK_EXEC, spawnFn, emit: collect() }); + assert.equal(acquireLock(p.lockFile, { pid: process.pid }), true); +}); + +test("runCommand kills a run that exceeds its timeout", async () => { + const emit = collect(); + const spawnFn = fakeSpawn([], { hang: true }); + await runCommand({ + event: { text: "go", from_id: 42 }, paths: paths(), + execCfg: { ...OK_EXEC, timeout_minutes: 0.001 }, spawnFn, emit, + }); + assert.equal(emit.out.at(-1).type, "exec_error"); + assert.match(emit.out.at(-1).detail, /timed out/i); +}); + +test("runCommand starts a fresh session for /new and drops the marker from the prompt", async () => { + const p = paths(); + saveSession(p.sessionFile, "old-session"); + const spawnFn = fakeSpawn([{ type: "result", subtype: "success", result: "ok", is_error: false }]); + await runCommand({ event: { text: "/new refactor the parser", from_id: 42 }, paths: p, execCfg: OK_EXEC, spawnFn, emit: collect() }); + const args = spawnFn.calls[0].args; + assert.ok(args.includes("--session-id"), "a /new command must not resume the old session"); + assert.equal(args[1], "refactor the parser"); +}); + +test("runCommand terminates the child, reports cancellation, and frees the lock when aborted", async () => { + const emit = collect(); + const p = paths(); + const spawnFn = fakeSpawn([], { hang: true }); + const ac = new AbortController(); + setTimeout(() => ac.abort(), 20); + await runCommand({ + event: { text: "go", from_id: 42 }, paths: p, + execCfg: { ...OK_EXEC, timeout_minutes: 0.05 }, spawnFn, emit, signal: ac.signal, + }); + assert.equal(emit.out.at(-1).type, "exec_error"); + assert.match(emit.out.at(-1).detail, /cancelled/i); + assert.equal(acquireLock(p.lockFile, { pid: process.pid }), true); +}); diff --git a/extensions/telegram/plugin.test.mjs b/extensions/telegram/plugin.test.mjs new file mode 100644 index 0000000..7561f3a --- /dev/null +++ b/extensions/telegram/plugin.test.mjs @@ -0,0 +1,88 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { classifyMessage, formatEvent } from "./telegram-plugin.mjs"; + +// ---------- classifyMessage ---------- + +test("classifyMessage routes /status to the status reply", () => { + assert.deepEqual(classifyMessage("/status"), { kind: "status" }); +}); + +test("classifyMessage routes /status with a bot suffix", () => { + assert.deepEqual(classifyMessage("/status@my_bot"), { kind: "status" }); +}); + +test("classifyMessage routes /cancel to a cancel event", () => { + assert.deepEqual(classifyMessage("/cancel"), { kind: "cancel" }); +}); + +test("classifyMessage treats free text as a command", () => { + assert.deepEqual(classifyMessage("run the test suite"), { kind: "command", text: "run the test suite" }); +}); + +test("classifyMessage keeps /new as part of the command text", () => { + assert.deepEqual(classifyMessage("/new refactor the parser"), { kind: "command", text: "/new refactor the parser" }); +}); + +test("classifyMessage ignores empty or whitespace-only messages", () => { + assert.equal(classifyMessage(" ").kind, "ignore"); + assert.equal(classifyMessage(undefined).kind, "ignore"); +}); + +// ---------- formatEvent: exec events ---------- + +const NOTIF = { approvals: true, failures: true }; + +test("formatEvent announces the start of a run", () => { + const out = formatEvent({ type: "exec_started", request: "run tests" }, NOTIF); + assert.equal(out.wanted, true); + assert.match(out.text, /run tests/); +}); + +test("formatEvent relays a successful result", () => { + const out = formatEvent({ type: "exec_result", detail: "2 files changed" }, NOTIF); + assert.equal(out.wanted, true); + assert.match(out.text, /2 files changed/); +}); + +test("formatEvent relays an error", () => { + const out = formatEvent({ type: "exec_error", detail: "boom" }, NOTIF); + assert.equal(out.wanted, true); + assert.match(out.text, /boom/); +}); + +test("formatEvent relays a denial so a blocked sender is never silently ignored", () => { + const out = formatEvent({ type: "exec_denied", detail: "sender is not authorized" }, NOTIF); + assert.equal(out.wanted, true); + assert.match(out.text, /not authorized/); +}); + +test("formatEvent sends progress by default", () => { + assert.equal(formatEvent({ type: "exec_progress", detail: "Edit b.ts" }, NOTIF).wanted, true); +}); + +test("formatEvent suppresses progress when exec_progress is disabled", () => { + const out = formatEvent({ type: "exec_progress", detail: "Edit b.ts" }, { ...NOTIF, exec_progress: false }); + assert.equal(out.wanted, false); +}); + +test("formatEvent never suppresses a result even when progress is off", () => { + const out = formatEvent({ type: "exec_result", detail: "done" }, { ...NOTIF, exec_progress: false }); + assert.equal(out.wanted, true); +}); + +test("formatEvent escapes html in executor output", () => { + const out = formatEvent({ type: "exec_result", detail: "" }, NOTIF); + assert.ok(!out.text.includes("