From a7abbaaca73fc76494664923947451f9044142a1 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 08:43:00 -0500 Subject: [PATCH 01/45] docs: console JSON editors and fix flow spec Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2026-09-25-console-json-editors-design.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-25-console-json-editors-design.md diff --git a/docs/superpowers/specs/2026-09-25-console-json-editors-design.md b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md new file mode 100644 index 00000000..4128dfda --- /dev/null +++ b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md @@ -0,0 +1,136 @@ +# Console JSON editors and fix flow (spec 2 of 3) + +**Status:** approved in brainstorming, 2026-09-25 +**Repo:** apps (`apps/console`, `apps/board`, `packages/ui`) +**Depends on:** spec 1, settings schemas and validation (rt), shipped as the +rt-client and settings-kit releases it names +**Sibling:** spec 3, settings migrations (rt), independent of this one + +## Why + +Console's settings page can edit the 27 JSON keys that settings-kit shaped +(string lists, string maps, flat leaves); the other 28 are read-only. Spec 1 gives +every JSON key a registry schema, delivered on the def as JSON Schema, plus a +`nonconforming` label on stored values that fail it. This spec uses both: forms for +the common shapes, a JSON editor for everything, and a way to fix broken values. + +## Goals + +1. Every writable JSON key is editable in console, inline on its row and in the + explain modal. +2. Common shapes get a form; every JSON key also gets an Edit-as-JSON editor. +3. An edit is checked against the key's schema as you type; an invalid value can + never be saved. +4. Stored values that fail their schema are easy to find and fix. +5. Board's settings modal moves to the same schema-based widgets. + +## Non-goals + +- Schemas themselves, server validation, `rt settings check` (spec 1). +- Migrations (spec 3). +- Editing `external` keys (board edits those in its own UI) or secrets. + +## Design + +### Picking the editor + +settings-kit's `recognize(schema)` (spec 1) picks the editor. Every JSON row keeps +its collapsed summary ("3 items", "2 fields", the first entries) and expands in place. + +| Recognized as | Editor | +|---|---| +| `stringList` | tag pills (today's widget, unchanged) | +| `stringMap` | key/value rows (today's widget, unchanged) | +| `leaves` | labeled fields (today's widget, unchanged) | +| `objectList` (array of objects with scalar properties) | item cards | +| `objectMap` (map of string to object with scalar properties) | named sections | +| `json` (anything else) | JSON editor only | + +### Item cards (`objectList`) + +- One card per item, in order, with Add item, Remove, and Move up / Move down. +- Each card shows a field for every required property plus every optional property + the item already sets. An "Add property" menu lists the optional properties the + schema allows and the item does not set yet; a set optional property has a remove + control, a required one does not. +- Field controls follow today's scalar rules: enum → Select, boolean → Switch, + number → NumberInput, string → TextInput (Autocomplete where suggestions exist). +- Unknown extra properties (allowed by spec 1's lenient schemas) are shown read-only + at the bottom of the card with a note to use Edit as JSON, never dropped on save. +- New items start from the schema's defaults for required properties, else empty, + and show their errors until filled. + +### Named sections (`objectMap`) + +- One section per entry, titled by its name, with Remove; "Add entry" asks for a + name (rejecting duplicates and empty names), then shows that entry's fields. +- Inside a section: the same field grid and "Add property" as an item card. + +### JSON editor + +- Every JSON row and the explain modal have an "Edit as JSON" toggle; for `json` + keys it is the only editor. +- Built on the kit's lazy `CodeMirror` (`@mattstack/app-kit/lazy`) in `json` mode. + The kit gains a `jsonSchema?: object` prop that lazily adds schema linting (errors + underlined at their exact path) and completion of property names and enum values. + CodeMirror packages stay inside the kit, per the import wall. +- The draft is pretty-printed with two-space indent. Save is disabled while the text + does not parse or fails the schema; the first issue shows under the editor. +- Escape abandons the edit and does not close the explain modal (the same rule the + modal already applies to fields). +- Switching between form and JSON keeps the draft; switching to the form is disabled + while the JSON does not parse, with a note saying why. + +### What an edit writes + +- Every editor edits the target layer's own stored value, never the merged view, + using `leafWrite`/`fieldSource`'s rule from view.ts: for a deep-merge key, the + draft starts from that layer's authored value, so defaults and other layers' + fields are never copied into the store being written. +- The draft is checked with settings-kit's `checkValue(schema, value)` as it changes; + the server's `/set` check (spec 1) remains the final word, and a refusal shows on + the row like any other refused save. + +### Fixing broken values + +- The toolbar gains a "Needs fixing N" chip next to Changed and Editable. N counts + keys with any `nonconforming` or `invalid` layer; the chip filters to them. +- A flagged row shows a warning line listing each issue with its path and layer, for + example `user · [2].url: expected string`. +- "Fix" on that line opens the key's explain modal with that layer's editor open + (form when it can draw the value, else JSON), the issues highlighted, and Save + disabled until the value passes. "Remove from " stays available. +- Values that fail the type check (skipped by rt today) take the same path; their + editor opens in JSON. + +### Board + +- Board's settings modal moves off `SHAPES` to `recognize(schema)` with the same + widgets, and gains item cards, named sections and the JSON editor for keys it + shows. Its `external` keys keep their own UIs. + +## Testing + +- Tests first for each editor: add, remove, reorder and add-property on item cards; + add and remove entry on named sections; JSON parse and schema errors blocking Save; + switching between form and JSON keeping the draft; Escape abandoning without + closing the modal; unknown extra properties preserved on save. +- A deep-merge key edited through each editor writes only the target layer's own + fields. +- Needs-fixing chip counts and filters; Fix opens the right layer's editor. +- Previously shaped keys render exactly as before (existing console and board tests + stay green). +- UI validation: Fast Browser against a branch build on live data, both schemes, for + one key of each editor kind and the fix flow, compared with the current page. + Never save through the test server; check `rt settings get + rt.notify.eventBridges --json` afterwards. + +## Acceptance + +- `rt.notify.eventBridges` can be edited as item cards and as JSON; an item without + `pattern` cannot be saved. +- `deck.apps` can be edited as named sections. +- A key the forms cannot draw is editable as JSON with inline schema errors. +- A nonconforming stored value appears under Needs fixing and can be fixed from the + modal. +- Nothing about today's shaped keys changes visually. From 3cfb698dbf8bcb1017e1aa51251b9658280b1cc4 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 09:00:12 -0500 Subject: [PATCH 02/45] docs: spec 2 review round 1 fixes, per-repo view, page audit findings Co-Authored-By: Claude Fable 5.1 --- .../2026-09-25-console-json-editors-design.md | 176 +++++++++++++----- 1 file changed, 130 insertions(+), 46 deletions(-) diff --git a/docs/superpowers/specs/2026-09-25-console-json-editors-design.md b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md index 4128dfda..0453a264 100644 --- a/docs/superpowers/specs/2026-09-25-console-json-editors-design.md +++ b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md @@ -1,28 +1,51 @@ -# Console JSON editors and fix flow (spec 2 of 3) +# Console JSON editors, per-repo view and fix flow (spec 2 of 3) -**Status:** approved in brainstorming, 2026-09-25 -**Repo:** apps (`apps/console`, `apps/board`, `packages/ui`) +**Status:** approved in brainstorming, 2026-09-25; revised after review round 1 and +the settings page audit +**Repo:** apps (`apps/console`, `apps/board`, `apps/boxscore`, `packages/ui`, +`packages/server`) **Depends on:** spec 1, settings schemas and validation (rt), shipped as the rt-client and settings-kit releases it names -**Sibling:** spec 3, settings migrations (rt), independent of this one +**Sibling:** spec 3, settings migrations (rt). This spec does not need spec 3, but +reads its fields when present (see "Values from older schema versions"). ## Why -Console's settings page can edit the 27 JSON keys that settings-kit shaped -(string lists, string maps, flat leaves); the other 28 are read-only. Spec 1 gives -every JSON key a registry schema, delivered on the def as JSON Schema, plus a -`nonconforming` label on stored values that fail it. This spec uses both: forms for -the common shapes, a JSON editor for everything, and a way to fix broken values. +Console's settings page can edit 24 JSON keys (settings-kit's shaped ones); the other +28 have no editor. An audit of the page on 2026-09-25 (103 keys against Matt's three +stores) found more: + +1. **Per-repo values are invisible.** Nine repo-scoped keys (`rt.roles`, + `rt.intercepts`, `rt.ignoredMrs`, `rt.sync`, `rt.branchNaming`, `rt.variations`, + `rt.presets`, `rt.dopplerTemplate`, and `rt.worktrees`) show "unset" or "default" + while the team or user store sets them for specific repos. The page resolves with + no repo, so rt omits repo rungs; the explain modal has the same blind spot. +2. **Editing a repo-scoped key writes an all-repos value**, with nothing on the row + saying so. +3. **JSON values cannot be read.** Rows show a count, and the explain modal cuts + every value at 40 characters. +4. `rt.worktreeReadyApproval` (a hash approving the team's worktree `ready` shell) + is a free-text box. +5. Unregistered keys left in stores (`board.claudeCommand`, `board.rtRepos` in the + machine store) are never shown. + +Everything else checked out: all scalar, switch and select values render their data, +no select holds a value outside its options, badges match the effective scope. + +Spec 1 gives every JSON key a schema delivered as JSON Schema, per-layer issues on +`/defs`, repo resolution (`?repo=`) and a repo list. This spec uses them. ## Goals -1. Every writable JSON key is editable in console, inline on its row and in the +1. Every writable JSON key is readable and editable in console, inline and in the explain modal. 2. Common shapes get a form; every JSON key also gets an Edit-as-JSON editor. 3. An edit is checked against the key's schema as you type; an invalid value can never be saved. -4. Stored values that fail their schema are easy to find and fix. -5. Board's settings modal moves to the same schema-based widgets. +4. Per-repo values are visible and editable for a chosen repo, and a write's target + (all repos or one repo, and which layer) is always stated. +5. Stored values that fail their schema are easy to find and fix. +6. Board and boxscore move to the same schema-based widgets. ## Non-goals @@ -32,20 +55,54 @@ the common shapes, a JSON editor for everything, and a way to fix broken values. ## Design +### Repo picker + +- The toolbar gains a repo select after the scope filter, fed by settings-kit's + `GET /repos`: "All repos" (default) plus each identity by its display label. The + choice lives in `?repo=` with the filter's params, so a reload keeps it. +- **All repos:** repo-scoped rows resolve as today (top-level layers only), and a + row whose key is set in any repo section says so next to its source, for example + "set in 2 repos", from `def.repos`. The row's control is labeled "all repos" so a + write's reach is visible. The explain modal lists each repo that sets the key, its + layer and value, and a link that switches the page to that repo. +- **One repo:** `/defs?repo=` resolves repo-scoped keys for it; rows show the repo's + effective value and badge the repo rung (`team · repo`, `you · repo`, + `machine · repo`). Edits write the repo section of the target layer (`repo` in the + `/set` body); the control is labeled with the repo. Non-repo keys are unchanged. +- The Changed count and the Needs-fixing count follow the picked repo. + +### Reading JSON values + +- Every JSON row expands to show its value pretty-printed (two-space indent, + monospace, wrapping) when it has no form, and the explain modal's layer lines show + each layer's full value the same way instead of cutting at 40 characters. Long + values scroll inside a capped block. + ### Picking the editor settings-kit's `recognize(schema)` (spec 1) picks the editor. Every JSON row keeps -its collapsed summary ("3 items", "2 fields", the first entries) and expands in place. +its collapsed summary and expands in place. | Recognized as | Editor | |---|---| | `stringList` | tag pills (today's widget, unchanged) | -| `stringMap` | key/value rows (today's widget, unchanged) | -| `leaves` | labeled fields (today's widget, unchanged) | +| `stringMap` | key/value rows (today's widget, with `labels` from the schema) | +| `leaves` | labeled fields (today's widget, with `placeholder`s from the schema) | | `objectList` (array of objects with scalar properties) | item cards | | `objectMap` (map of string to object with scalar properties) | named sections | | `json` (anything else) | JSON editor only | +For deep-merge keys the forms use the layer schema (spec 1), so every property is +optional within a layer and the summary names which fields this layer sets. + +### Save model + +- Scalar controls, tag pills and key/value rows keep today's save-on-commit. +- Item cards, named sections and the JSON editor edit a local draft with Save and + Cancel on the row (and in the modal), because their in-between states (a new item + with empty required fields) are invalid. Save is disabled while the draft fails + its schema; Cancel and Escape discard the draft. + ### Item cards (`objectList`) - One card per item, in order, with Add item, Remove, and Move up / Move down. @@ -54,11 +111,11 @@ its collapsed summary ("3 items", "2 fields", the first entries) and expands in schema allows and the item does not set yet; a set optional property has a remove control, a required one does not. - Field controls follow today's scalar rules: enum → Select, boolean → Switch, - number → NumberInput, string → TextInput (Autocomplete where suggestions exist). + number → NumberInput, string → TextInput (Autocomplete where suggestions exist); + `title`, `description` and `placeholder` come from the schema's metadata. - Unknown extra properties (allowed by spec 1's lenient schemas) are shown read-only - at the bottom of the card with a note to use Edit as JSON, never dropped on save. -- New items start from the schema's defaults for required properties, else empty, - and show their errors until filled. + at the bottom of the card with a note to use Edit as JSON, and are kept on save. +- New items start from the schema's defaults for required properties, else empty. ### Named sections (`objectMap`) @@ -76,8 +133,7 @@ its collapsed summary ("3 items", "2 fields", the first entries) and expands in CodeMirror packages stay inside the kit, per the import wall. - The draft is pretty-printed with two-space indent. Save is disabled while the text does not parse or fails the schema; the first issue shows under the editor. -- Escape abandons the edit and does not close the explain modal (the same rule the - modal already applies to fields). +- Escape abandons the edit and does not close the explain modal. - Switching between form and JSON keeps the draft; switching to the form is disabled while the JSON does not parse, with a note saying why. @@ -87,48 +143,76 @@ its collapsed summary ("3 items", "2 fields", the first entries) and expands in using `leafWrite`/`fieldSource`'s rule from view.ts: for a deep-merge key, the draft starts from that layer's authored value, so defaults and other layers' fields are never copied into the store being written. -- The draft is checked with settings-kit's `checkValue(schema, value)` as it changes; - the server's `/set` check (spec 1) remains the final word, and a refusal shows on - the row like any other refused save. +- The draft is checked with settings-kit's `checkValue` against the key's schema + (the layer schema for deep keys) as it changes. The server's `validateWrite` + (spec 1) remains the final word, including the merged-result check; a refusal + shows on the row like any other refused save. ### Fixing broken values - The toolbar gains a "Needs fixing N" chip next to Changed and Editable. N counts - keys with any `nonconforming` or `invalid` layer; the chip filters to them. -- A flagged row shows a warning line listing each issue with its path and layer, for + keys with any entry in `def.issues` or `def.mergedIssues` (from `/defs`, no + per-key calls); the chip filters to them. +- A flagged row shows a warning line per issue with its layer (and repo), for example `user · [2].url: expected string`. -- "Fix" on that line opens the key's explain modal with that layer's editor open - (form when it can draw the value, else JSON), the issues highlighted, and Save - disabled until the value passes. "Remove from " stays available. -- Values that fail the type check (skipped by rt today) take the same path; their - editor opens in JSON. +- "Fix" on that line opens the key's explain modal (switching the picked repo when + the issue is in a repo section) with that layer's editor open, form when it can + draw the value, else JSON, with the issues highlighted and Save disabled until the + value passes. "Remove from " stays available. +- Values that fail the type check (skipped by rt) take the same path, in JSON. + +### Special rows + +- `rt.worktreeReadyApproval` is read-only, explained ("approves the team's worktree + `ready` commands by their hash; approve with `rt worktree ready-approve`"), with a + Revoke action that removes it from its layer. +- A footer note lists unregistered keys found in stores (from `rt settings check`'s + data via settings-kit), with their file, and says rt ignores them. + +### Values from older schema versions + +- When explain rows carry spec 3's `storedVersion` and migrated `value`, editors + start from the migrated value, and a save writes the current shape. A write that + spec 3's floor rule refuses shows its message on the row. Without spec 3 these + fields are absent and nothing changes. -### Board +### Writers and other apps -- Board's settings modal moves off `SHAPES` to `recognize(schema)` with the same - widgets, and gains item cards, named sections and the JSON editor for keys it - shows. Its `external` keys keep their own UIs. +- Board's settings modal and boxscore's settings move off `SHAPES` to + `recognize(schema)` with the same widgets; their `external` keys keep their own UIs. +- Writer tests in this repo, against the rt-client release from spec 1: console's + bridge-rule reconcile output for `rt.notify.eventBridges`, deck's `deck.apps` + writes, board's writes, boxscore's `mattstack.roster` and `boxscore.hiddenMembers` + writes each pass `validateWrite`. ## Testing - Tests first for each editor: add, remove, reorder and add-property on item cards; - add and remove entry on named sections; JSON parse and schema errors blocking Save; - switching between form and JSON keeping the draft; Escape abandoning without - closing the modal; unknown extra properties preserved on save. + add and remove entry on named sections; Save disabled on invalid drafts; JSON parse + and schema errors; switching between form and JSON keeping the draft; Escape + abandoning without closing the modal; unknown extra properties preserved. - A deep-merge key edited through each editor writes only the target layer's own fields. -- Needs-fixing chip counts and filters; Fix opens the right layer's editor. -- Previously shaped keys render exactly as before (existing console and board tests - stay green). +- Repo picker: per-repo rows show repo values and rungs; writes carry `repo`; "set + in N repos" with all repos; the Changed count follows the picked repo. +- Needs-fixing chip counts and filters from `/defs`; Fix opens the right layer and + repo. +- Previously shaped keys render exactly as before (existing console, board and + boxscore tests stay green). - UI validation: Fast Browser against a branch build on live data, both schemes, for - one key of each editor kind and the fix flow, compared with the current page. - Never save through the test server; check `rt settings get - rt.notify.eventBridges --json` afterwards. + one key of each editor kind, the repo picker on the team repo that carries the + per-repo values (identity invented in fixtures, e.g. `gitlab.example.com/acme/app`), + and the fix flow, compared with the current page. Never save through the test + server; check `rt settings get rt.notify.eventBridges --json` afterwards. ## Acceptance -- `rt.notify.eventBridges` can be edited as item cards and as JSON; an item without - `pattern` cannot be saved. +- With the team repo picked (`gitlab.example.com/acme/app` in the fixture), + `rt.roles`, `rt.intercepts` and the + other seven audit keys show the team store's repo values; with All repos, each + says "set in 1 repo". +- `rt.notify.eventBridges` can be read in full, edited as item cards and as JSON; an + item without `pattern` cannot be saved. - `deck.apps` can be edited as named sections. - A key the forms cannot draw is editable as JSON with inline schema errors. - A nonconforming stored value appears under Needs fixing and can be fixed from the From 3fe934d5d4cc6e287a0a57a8c333c7141ce9235b Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 09:14:05 -0500 Subject: [PATCH 03/45] docs: spec 2 follows spec 3's versioned store names, no floor rule Co-Authored-By: Claude Fable 5.1 --- .../specs/2026-09-25-console-json-editors-design.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-09-25-console-json-editors-design.md b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md index 0453a264..cecb5527 100644 --- a/docs/superpowers/specs/2026-09-25-console-json-editors-design.md +++ b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md @@ -172,8 +172,7 @@ optional within a layer and the summary names which fields this layer sets. ### Values from older schema versions - When explain rows carry spec 3's `storedVersion` and migrated `value`, editors - start from the migrated value, and a save writes the current shape. A write that - spec 3's floor rule refuses shows its message on the row. Without spec 3 these + start from the migrated value, and a save writes the current shape under the current store name (`key@N`). Without spec 3 these fields are absent and nothing changes. ### Writers and other apps From 5ae1f620af9a84255e90bcbb4a243b514132c1cc Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 09:17:55 -0500 Subject: [PATCH 04/45] docs: spec 2 handles a diverged store name in the fix flow Co-Authored-By: Claude Fable 5.1 --- .../specs/2026-09-25-console-json-editors-design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/superpowers/specs/2026-09-25-console-json-editors-design.md b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md index cecb5527..146152f1 100644 --- a/docs/superpowers/specs/2026-09-25-console-json-editors-design.md +++ b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md @@ -174,6 +174,11 @@ optional within a layer and the summary names which fields this layer sets. - When explain rows carry spec 3's `storedVersion` and migrated `value`, editors start from the migrated value, and a save writes the current shape under the current store name (`key@N`). Without spec 3 these fields are absent and nothing changes. +- A `diverged` issue (spec 3: an older store name edited after the current one + exists) shows both values on the row. Fix opens the editor on the current value + with a "Use the older value" action that replaces the draft with the older, + migrated value; Save writes the current name either way, and the older name is + left for `rt settings migrate --prune`. ### Writers and other apps From 0f0dc340fab80751cf0ab294255fdeaa10eda40d Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 09:22:30 -0500 Subject: [PATCH 05/45] docs: spec 2 diverged fix flow can remove the older store name Co-Authored-By: Claude Fable 5.1 --- .../specs/2026-09-25-console-json-editors-design.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-09-25-console-json-editors-design.md b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md index 146152f1..4a443fdb 100644 --- a/docs/superpowers/specs/2026-09-25-console-json-editors-design.md +++ b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md @@ -174,11 +174,13 @@ optional within a layer and the summary names which fields this layer sets. - When explain rows carry spec 3's `storedVersion` and migrated `value`, editors start from the migrated value, and a save writes the current shape under the current store name (`key@N`). Without spec 3 these fields are absent and nothing changes. -- A `diverged` issue (spec 3: an older store name edited after the current one - exists) shows both values on the row. Fix opens the editor on the current value - with a "Use the older value" action that replaces the draft with the older, - migrated value; Save writes the current name either way, and the older name is - left for `rt settings migrate --prune`. +- A `diverged` issue (spec 3: an older store name changed after the current one + was written, per its recorded baseline) shows both values on the row. Fix opens + the editor on the current value with a "Use the older value" action that replaces + the draft with the older, migrated value; Save writes the current name either + way. The modal also offers "Remove the older name", which confirms while showing + the older value and deletes that name and its baseline through the same path + `rt settings migrate --prune --force` uses; until then the issue stays listed. ### Writers and other apps From 6296e43f099182cebbd9e95a434d2c1ee40343cd Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 09:25:22 -0500 Subject: [PATCH 06/45] docs: spec 2 removes an older store name through settings-kit /prune Co-Authored-By: Claude Fable 5.1 --- .../specs/2026-09-25-console-json-editors-design.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-09-25-console-json-editors-design.md b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md index 4a443fdb..dcb601b8 100644 --- a/docs/superpowers/specs/2026-09-25-console-json-editors-design.md +++ b/docs/superpowers/specs/2026-09-25-console-json-editors-design.md @@ -179,8 +179,7 @@ optional within a layer and the summary names which fields this layer sets. the editor on the current value with a "Use the older value" action that replaces the draft with the older, migrated value; Save writes the current name either way. The modal also offers "Remove the older name", which confirms while showing - the older value and deletes that name and its baseline through the same path - `rt settings migrate --prune --force` uses; until then the issue stays listed. + the older value and deletes that name and its baseline through settings-kit's `/prune` route (spec 3, forced for a diverged name); until then the issue stays listed. ### Writers and other apps From a930263c4308b9000a95e6270033cf83f22c0b0a Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 15:35:37 -0500 Subject: [PATCH 07/45] docs: plan 2, console JSON editors, per-repo view and fix flow (draft for review) Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-25-console-json-editors.md | 6022 +++++++++++++++++ 1 file changed, 6022 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-25-console-json-editors.md diff --git a/docs/superpowers/plans/2026-09-25-console-json-editors.md b/docs/superpowers/plans/2026-09-25-console-json-editors.md new file mode 100644 index 00000000..f20425ef --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-console-json-editors.md @@ -0,0 +1,6022 @@ +# Console JSON Editors, Per-Repo View and Fix Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every writable JSON settings key is readable and editable in console (forms where the shape allows, JSON everywhere), per-repo values are visible and editable for a picked repo, stored values that fail their schema are easy to find and fix, and board and boxscore render their composite rows from the schema instead of hand-kept tables. + +**Architecture:** The apps move to rt-client 0.32.0 and settings-kit 0.4.0 (plan 1), which put each key's JSON Schema, per-layer issues, repo resolution and a repo list on the settings-kit wire. Console replaces settings-kit's React hooks with two small repo-aware hooks of its own (the kit's hooks take no repo), keeps the picked repo in `?repo=` and a React context, and draws every composite row from `recognize(def.schema)` plus a console-side `formShape(schema)` that also admits maps and lists of objects whose optional properties are nested (drawn read-only). Item cards, named sections and a JSON editor share one draft model (`DraftEditor`): a local draft checked by settings-kit's `checkValue` against the layer schema, saved only when it passes. The JSON editor is the kit's lazy `CodeMirror`, which gains `jsonSchema` (completion) and `jsonCheck` (lint through the caller's checker, so underline text matches the issue line) behind its lazy boundary. + +**Tech Stack:** Bun 1.4.2, TypeScript, React 19, Mantine 9.5 through `@mattstack/app-kit`, wouter, Vitest + Testing Library (console, boxscore, packages/ui), bun:test (board, deck), CodeMirror 6 (`@codemirror/lint`, `@codemirror/autocomplete`, `@codemirror/language`), `@mattstack/rt-client` 0.32.0, `@mattstack/settings-kit` 0.4.0. + +**Spec:** `docs/superpowers/specs/2026-09-25-console-json-editors-design.md` (spec 2). Its dependency, read-only: spec 1 at `~/.mattstack/rt/worktrees/gh-m4ttstack-rt/bilbo/docs/superpowers/specs/2026-09-25-settings-schemas-design.md` and plan 1 at `.../bilbo/docs/superpowers/plans/2026-09-25-settings-schemas.md`. Spec 3 (`.../bilbo/docs/superpowers/specs/2026-09-25-settings-migrations-design.md`) is read only for the diverged wire fields pinned below. + +## Baseline (A0) + +On 2026-09-25 at `6296e43f`: `bun install` exit 0; `bun run console:test` 66 files, 843 tests passed. The first run printed one unhandled error from `src/app/App.test.tsx` (a `deckBase` effect settling after teardown) and exited 1; the immediate rerun exited 0 with no error. Treat that App.test.tsx unhandled error as a known flake: if it appears in a task gate, rerun the gate once; if it appears twice in a row, stop and report it as BLOCKED instead of changing App.test.tsx. + +## Decisions made while planning + +- **deck.apps as named sections (Matt, gate ee475cb4, q1).** Plan 1's `recognize` calls `deck.apps` `json` because its `override` property is a nested object. Console's `formShape` admits a list or map of objects whenever every required property is a scalar and at least one property is; nested properties are drawn read-only with an "Edit as JSON" note, exactly like unknown extras, and kept on save. Under the current lock this admits, beyond recognize's own objectList/objectMap keys: `deck.apps`, `rt.roles`, `rt.sdmEnrichment` (named sections). `rt.intercepts`, `rt.presets` and `board.tabs` stay JSON (a required property is nested). +- **Diverged wire fields (Matt, gate ee475cb4, q2).** Pinned here; the spec 3 planner (`plan-settings-migrations`) was told by rt chat DM #3691 to match: + - `/defs` `issues[]` entry: `{ scope, file, repo?, kind: "diverged", path: [], message, storeName, olderValue, currentValue }`. `storeName` is the OLDER store name (`rt.roles` when the current one is `rt.roles@2`); `olderValue` is that name's value migrated to the current shape; `currentValue` is the current name's authored value in the same section. Neither value is sent for a secret def. + - Explain rows carry `storeName`, `storedVersion`, `value` (migrated) and `authored` (as stored). Console reads only `value`, which is what every editor already starts from. + - `POST {base}/prune` with `{ key, scope, repo?, storeName, force? }`, same local-only gate as `/set`; success `{ rows, effective }` 200, refusal `{ error }` 400. +- **Repo-aware hooks live in console.** settings-kit 0.4.0's `useSettingsScope` and `useSettingKey` send no `repo` and drop `unregistered`, so console owns `useConsoleSettings(repo, prefix)` and `useKeyExplain(key, repo)`. Move stays settings-kit's (`move.ts` is not exported), borrowed from a `useSettingsScope` instance whose prefix matches no key; in a picked repo, Move is offered only when the value comes from a global layer. Remove works everywhere. +- **CodeMirror lints through the caller's checker.** The kit takes `jsonCheck?: (value) => { path, message }[]` beside `jsonSchema?: object`, so the underline text and console's issue line are the same `checkValue` output and the kit gains no validator. The completion and path mapping are hand-rolled (about 200 lines); `codemirror-json-schema` pulls in shiki and markdown-it. +- **Where a row states its reach.** A repo-scoped row's reach (`all repos · set in 2 repos`, or `for acme/app`) sits on the row's name line after its badge, not inside the 260px control column, so no control shrinks. A repo rung's badge reads `team · repo`, `user · repo`, `machine · repo`, keeping the badges' existing `user` word (the spec's example writes `you · repo`; the page's badges have always said `user`). +- **Board keeps its widgets.** Board's composite keys recognize as `stringList` and `leaves` only (plus its three `external` keys), so "the same schema-based widgets" means board's existing ChipControl and LeavesControl, now chosen by `recognize`. Any other kind on a board key renders read-only. + +## Global Constraints + +- rt-client and settings-kit pins: `"@mattstack/rt-client": "0.32.0"`, `"@mattstack/settings-kit": "0.4.0"` in the root `package.json` `workspaces.catalog`. If `npm view @mattstack/settings-kit@0.4.0 version` or `npm view @mattstack/rt-client@0.32.0 version` prints nothing, stop and report BLOCKED: plan 1 has not published. +- Every console task gate, run bare from the worktree root, each command on its own (never piped through `tail`, `head` or `grep`; a pipe hides the exit code): `bun run console:typecheck`, `bun run console:lint`, `bun run console:test`, `sh scripts/repo-purity.sh`, `bun run format:check`. Tasks that touch board, boxscore, deck or packages/ui add those suites' own commands as listed in the task. +- Commit trailer on every commit, verbatim: `Co-Authored-By: Claude Fable 5.1 `. Commit on branch `console-json-editors`; never push. +- Fixture data is invented: repo identity `gitlab.example.com/acme/app` (label `acme/app`), a second `gitlab.example.com/acme/web` (label `acme/web`), files under `/home/user/...` and `/home/team/...`. No real repo identities, team names, people or employer names anywhere. +- No em or en dashes in any new text: code, comments, test names, UI copy, commit messages. +- Comments state constraints the code cannot show; no narration, no task numbers, no review history in source. +- UI colour and type follow `docs/ui-authoring.md`: `--tk-*` role tokens or `useSchemeColors()` roles only, weights 400/500/700, every `Text` states `fz`. Error text is `var(--tk-text-bad-small)`, warning text `var(--tk-text-warn-small)`, muted `text.muted`. No raw colour values. +- Mantine: look it up, never recall it. Before using any Mantine component or prop not already used in `apps/console/src/app/settings/`, check it with the Mantine MCP (`get_item_props`) or the installed `@mantine/core` type declarations (`node_modules/.bun/@mantine+core@*/node_modules/@mantine/core/lib/components//.d.ts`). +- Import walls: app code imports Mantine only through `@mattstack/app-kit/*`; CodeMirror packages only inside `packages/ui/src/lazy/codemirror/`. +- UI validation is mandatory for every task that changes UI: a Fast Browser pass against a local console build on live data in BOTH colour schemes, following "UI validation recipe" below. Say plainly what looks wrong; tests passing is not validation. +- Never save through the test server. After every browser session: `rt settings get rt.notify.eventBridges --json` and confirm both `run:` and `gate/escalated/*` rule urls read `https://console.mattstack/gates/{id}`; if not, restore them with `rt settings set` to that value and say so in the report. + +## Review Focus + +1. **A deep-merge key edited through a form or JSON writes only the target layer's own fields.** Editing `gitq.forges` at `user` when the registry default also sets an entry must write exactly the user layer's authored map plus the edit, never a merged copy. Pinned in Task 9 (`a deep map edits only the target layer's own entries`). +2. **A repo-scoped write while a repo is picked carries `repo`, and a global value in a picked repo is still editable.** A row whose effective value comes from the global `team` layer, edited in `acme/app`, writes `team` with `repo: "gitlab.example.com/acme/app"` (a new repo override), never the global layer. Pinned in Task 5 (`useRowSave` target test). +3. **Escape inside the JSON editor abandons the draft and leaves the explain modal open.** CodeMirror's content element is `contenteditable="true"`, which `ESCAPE_OWNERS` already covers; a regression here closes the modal and loses the edit. Pinned in Task 10. +4. **An item card with an unknown extra property keeps it on save.** A stored `rt.notify.eventBridges` item with `{"legacy": 1}` is shown read-only and written back unchanged after editing another field. Pinned in Task 8. +5. **A nonconforming stored value never opens a form that would drop data.** A `rt.notify.eventBridges` user layer holding a non-array opens Fix in JSON, not in cards, and Save stays disabled until it passes. Pinned in Task 11. + +## UI validation recipe + +Every UI task ends with this, run by the implementer (or a `fast-browser:browser-driver` subagent given this whole section verbatim): + +1. `bun run console:build` (rebuilds `apps/console/dist`; `serve` answers the UI from it). +2. Print the current bridge rules with `rt settings get rt.notify.eventBridges --json` and keep the `url` values in your report notes (do not save them to a file). +3. Start the branch server in the background from `apps/console`: `bun run serve` (it listens on `http://localhost:11011`; the live console stays on deck's 11001). Wait until `curl -s http://localhost:11011/api/health` answers `{"ok":true,...}`. +4. Drive `http://localhost:11011/settings` with Fast Browser by raw port. Never open a `*.mattstack` URL: it hands the tab to the mattstack app and kills the session. Use one `browser_run_code_unsafe` script per scheme that first installs `await page.route(/\/api\/settings\/(set|unset|prune)$/, r => r.fulfill({ status: 409, contentType: 'application/json', body: JSON.stringify({ error: 'validation run: writes disabled' }) }))`, so no click can write, then sets `await page.emulateMedia({ colorScheme: 'light' })` (then `'dark'`), navigates, performs the task's checks, and takes screenshots. Compare with the live page at `http://localhost:11001/settings` in the same scheme where the task says "compared with the current page". +5. Stop the server (kill the background job), then run the bridge check from Global Constraints. +6. In the task report, list each screenshot, what it shows, and anything that reads wrong (clipping, contrast, alignment, a control that shifts the row height, text that wraps badly). Fix what reads wrong before committing, or report it as an open concern. + +--- + +## File structure + +**Root** +- `package.json` (modify): catalog pins for rt-client and settings-kit. + +**packages/ui (kit)** +- `packages/ui/package.json` (modify): dependencies gain `@codemirror/autocomplete`, `@codemirror/language`, `@codemirror/lint`. +- `packages/ui/src/lazy/codemirror/jsonSchema.ts` (create): `JsonPathIssue`, `JsonSchemaCheck`, `nodeAtPath`, `jsonDiagnostics`, `jsonSchemaCompletion`. Imported only by `CodeMirror.Base.tsx`. +- `packages/ui/src/lazy/codemirror/jsonSchema.test.ts` (create). +- `packages/ui/src/lazy/codemirror/CodeMirror.Base.tsx` (modify): `jsonSchema?`, `jsonCheck?` props in a compartment. +- `packages/ui/src/lazy/codemirror/CodeMirror.tsx`, `packages/ui/src/lazy/index.ts` (modify): export the two types. +- `packages/ui/src/lazy/codemirror/CodeMirror.stories.tsx` (modify): a `JsonWithSchema` story. + +**apps/console (`apps/console/src/app/settings/`)** +- `testSchemas.ts` (create): literal JSON Schemas for the keys console tests use; `testSchemas.test.ts` (create, node env) pins them to rt-client's registry. +- `issues.ts` (create): `issuePath`, `issueText`, `issuesUnder`, `issueWhere`, `issueLine`, `DivergedIssue`, `isDiverged`. +- `useConsoleSettings.ts` (create): `ConsoleStore`, `useConsoleSettings`, `useKeyExplain`, `useRepos`, `SettingsRepoContext`, `useSettingsRepo`, `prune`. +- `formShape.ts` (create): `FieldSpec`, `FormShape`, `formShape`, `newEntry`, `visibleFields`, `addableFields`, `extraKeys`. +- `FieldGrid.tsx` (create): one object's fields (controlled inputs, Add property menu, read-only extras). +- `ItemCards.tsx`, `NamedSections.tsx` (create): the two form editors over a draft value. +- `JsonDraft.tsx` (create): the JSON editor over the kit's `CodeMirror`. +- `DraftEditor.tsx` (create): the shared draft (form or JSON), Save and Cancel, first issue line. +- `JsonBlock.tsx` (create): pretty-printed capped value block. +- `IssueLines.tsx` (create): per-issue warning lines with Fix. +- Tests (create): `useConsoleSettings.test.tsx`, `formShape.test.ts`, `ItemCards.test.tsx`, `NamedSections.test.tsx`, `JsonEditor.test.tsx`, `FixFlow.test.tsx`, `SpecialRows.test.tsx`, `Diverged.test.tsx`. +- `RepoPicker.tsx` (create): the toolbar select. +- `RepoReach.tsx` (create): the row's "all repos · set in N repos" or repo label. +- `UnregisteredNote.tsx` (create): the footer note. +- `DivergedPanel.tsx` (create): both values, Use the older value, Remove the older name. +- Modified: `view.ts` (layer rungs, write targets, `repoLabel`, `targetLabel`, `EDITOR_KINDS`, `needsFixing`, `APPROVAL_KEY`), `useRowSave.ts`, `SettingRow.tsx`, `CompositeControls.tsx`, `ExplainModal.tsx`, `SettingsPage.tsx`, `SettingsSection.tsx`, `RowMenu.tsx`, `ScopeBadge.tsx`, `explainParam.ts`, `../config/chain.ts` (unchanged API; `shortValue` stays for the sentence), and their tests. +- `apps/console/src/server/event-bridge-validate.test.ts` (create): the reconciled rule passes `validateWrite`. + +**apps/board** +- `src/client/board/config-shapes.ts` (modify): `COMPOSITE_SHAPES` becomes `shapeOf(def)` over `recognize(def.schema)`. +- `src/client/board/ConfigModal.tsx` (modify): three call sites. +- `src/client/__tests__/config-shapes.test.ts` (modify). +- `src/__tests__/config-store-latch.test.ts` (modify): its `fakeWrite` holds every saver's write to `validateWrite`. +- `src/__tests__/writers-validate.test.ts` (create): the gate bridge rule passes `validateWrite`. + +**apps/boxscore** +- `src/app/settings/shapes.ts` (modify): `COMPOSITE_SHAPES` becomes `shapeOf(def)` over `recognize`. +- `src/app/settings/SettingsPage.tsx`, `SettingsPage.test.tsx` (modify). +- `src/app/settings/writers-validate.test.ts` (create, node env). + +**apps/deck** +- `core/settings.test.ts` (modify): every `deck.apps` write passes `validateWrite`. + +--- +### Task 1: Move to rt-client 0.32.0 and settings-kit 0.4.0; console and board read composite shapes from the schema + +settings-kit 0.4.0 shrinks `SHAPES` to the three `external` board keys and adds `recognize(schema)`. Without this task's code changes every console composite editor and board's `COMPOSITE_SHAPES` go read-only on the bump, so the bump and the switch to `recognize` land together. Previously shaped keys must render exactly as before. + +**Needs from spec 1's wire contract:** `SettingDefWire.storeVersion: number` (required), `schema?`, `layerSchema?` (deep object keys); `recognize(schema): Recognized` with kinds `stringList | stringMap | leaves | objectList | objectMap | json` (`leaves` carries `fields` and `placeholders`, `stringMap` carries `labels`); `matchesSchema(def, value)`; `SHAPES` holding only `board.tabs`, `board.members`, `board.hiddenMembers` as `external`; `RowKind` adding `objectList | objectMap | json`; `allowComposite: "shaped"` admitting every non-external composite key that has a schema (so those defs arrive `writable: true`); rt-client `getDef(key).schema` / `.layerSchema` attached from the lock. + +**Files:** +- Modify: `package.json` (root catalog) +- Create: `apps/console/src/app/settings/testSchemas.ts`, `apps/console/src/app/settings/testSchemas.test.ts` +- Modify: `apps/console/src/app/settings/CompositeControls.tsx`, `apps/console/src/app/settings/view.ts` +- Modify (test fixtures only): `apps/console/src/app/settings/{CompositeControls,SettingRow,SettingsPage,ExplainModal}.test.tsx`, `apps/console/src/app/settings/view.test.ts`, `apps/console/src/app/config/chain.test.ts` +- Modify: `apps/board/src/client/board/config-shapes.ts`, `apps/board/src/client/board/ConfigModal.tsx`, `apps/board/src/client/__tests__/config-shapes.test.ts` +- Modify (test fixture only): `apps/boxscore/src/app/settings/SettingsPage.test.tsx` + +**Interfaces:** +- Consumes: settings-kit 0.4.0 `recognize`, `matchesSchema`, `Recognized`; rt-client 0.32.0 `getDef`, `allDefs`. +- Produces: + - `testSchemas.ts`: `TEST_SCHEMAS: Record`, `layerOf(schema: JsonSchema): JsonSchema`, `schemaFields(key: string): { schema?: JsonSchema; layerSchema?: JsonSchema }` (layerSchema only for the keys listed in `DEEP_KEYS`). + - `view.ts`: `EDITOR_KINDS: ReadonlySet` (this task: `scalar`, `enum`, `stringList`, `stringMap`, `leaves`); `isEditable(def)` reads it. + - board `config-shapes.ts`: `shapeOf(def: Pick): CompositeShape | undefined` replacing `COMPOSITE_SHAPES`. + +- [ ] **Step 1: Confirm plan 1 has published** + +Run: `npm view @mattstack/rt-client@0.32.0 version` and `npm view @mattstack/settings-kit@0.4.0 version` +Expected: `0.32.0` and `0.4.0`. If either prints nothing, stop: report BLOCKED (plan 1 not published). + +- [ ] **Step 2: Bump the catalog and install** + +In the root `package.json` `workspaces.catalog`, change `"@mattstack/rt-client": "0.31.1"` to `"@mattstack/rt-client": "0.32.0"` and `"@mattstack/settings-kit": "0.3.0"` to `"@mattstack/settings-kit": "0.4.0"`. + +Run: `bun install` +Expected: exit 0, `bun.lock` changes only in the rt-client and settings-kit entries plus `@cfworker/json-schema` (settings-kit's new dependency). + +- [ ] **Step 3: See what breaks** + +Run: `bun run console:typecheck` then `bun run board:typecheck` then `bun run boxscore:typecheck` +Expected: FAIL. Every `SettingDefWire` literal in the test files listed under Files lacks `storeVersion`; board's `config-shapes.ts` still compiles but `COMPOSITE_SHAPES` is now nearly empty at runtime. + +- [ ] **Step 4: Write the fixture schemas and their parity test** + +Create `apps/console/src/app/settings/testSchemas.ts`: + +```ts +import type { SettingDefWire } from '@mattstack/settings-kit/react'; + +type JsonSchema = NonNullable; + +const D = 'https://json-schema.org/draft/2020-12/schema'; +const STRING_LIST = { $schema: D, type: 'array', items: { type: 'string' } }; + +/** Literal copies of the registry's JSON Schemas for the keys console tests + render. testSchemas.test.ts fails when one drifts from rt-client's. */ +export const TEST_SCHEMAS: Record = { + 'board.ticketPrefixes': STRING_LIST, + 'boxscore.excludeFilePatterns': STRING_LIST, + 'rt.repoRoots': STRING_LIST, + 'rt.homeSnapshot': { + $schema: D, + type: 'object', + properties: { + enabled: { type: 'boolean' }, + debounceSec: { type: 'number' }, + pushDelaySec: { type: 'number' }, + janitorThresholdHours: { type: 'number' }, + janitorIntervalMin: { type: 'number' }, + }, + required: [ + 'enabled', + 'debounceSec', + 'pushDelaySec', + 'janitorThresholdHours', + 'janitorIntervalMin', + ], + additionalProperties: {}, + }, + 'rt.runaway': { + $schema: D, + type: 'object', + properties: { + cpuThreshold: { type: 'number' }, + sustainMs: { type: 'number' }, + graceMs: { type: 'number' }, + }, + additionalProperties: {}, + }, + 'rt.repoIdentityOverrides': { + $schema: D, + type: 'object', + propertyNames: { type: 'string' }, + additionalProperties: { type: 'string' }, + labels: { key: 'remote URL', value: 'identity' }, + }, + 'rt.cron': { + $schema: D, + type: 'object', + properties: { + triggers: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 }, + event: { type: 'string', minLength: 1 }, + run: { minItems: 1, type: 'array', items: { type: 'string' } }, + repoName: { type: 'string' }, + debounceMs: { type: 'number', exclusiveMinimum: 0 }, + }, + required: ['name', 'event', 'run'], + additionalProperties: {}, + }, + }, + }, + additionalProperties: {}, + }, + 'rt.roles': { + $schema: D, + type: 'object', + propertyNames: { type: 'string' }, + additionalProperties: { + type: 'object', + properties: { + pool: { + type: 'array', + items: { + anyOf: [ + { type: 'number' }, + { + type: 'object', + properties: { from: { type: 'number' }, to: { type: 'number' } }, + required: ['from', 'to'], + additionalProperties: {}, + }, + ], + }, + }, + fixedPort: { type: 'number' }, + needs: { type: 'array', items: { type: 'string' } }, + preserveEnv: { type: 'array', items: { type: 'string' } }, + env: { + type: 'object', + propertyNames: { type: 'string' }, + additionalProperties: { type: 'string' }, + }, + hook: { type: 'string' }, + }, + additionalProperties: {}, + }, + }, + 'rt.intercepts': { + $schema: D, + type: 'array', + items: { + type: 'object', + properties: { + command: { type: 'string', minLength: 1 }, + matches: { + type: 'array', + items: { + type: 'object', + properties: { + cwdGlob: { type: 'string' }, + role: { type: 'string' }, + argPattern: { type: 'string' }, + argInject: { + type: 'object', + properties: { + afterArg: { type: 'string' }, + template: { type: 'string' }, + skipIfArgPresent: { type: 'string' }, + }, + required: ['afterArg', 'template', 'skipIfArgPresent'], + additionalProperties: {}, + }, + }, + required: ['cwdGlob', 'role'], + additionalProperties: {}, + }, + }, + }, + required: ['command', 'matches'], + additionalProperties: {}, + }, + }, + 'rt.notify.eventBridges': { + $schema: D, + type: 'array', + items: { + type: 'object', + properties: { + pattern: { type: 'string' }, + category: { type: 'string' }, + title: { type: 'string' }, + message: { type: 'string' }, + subjectPrefix: { type: 'string' }, + url: { type: 'string' }, + owner: { type: 'string', const: 'human' }, + surface: { type: 'string' }, + }, + required: ['pattern', 'category', 'title', 'message'], + additionalProperties: {}, + }, + }, + 'gitq.forges': { + $schema: D, + type: 'object', + propertyNames: { type: 'string' }, + additionalProperties: { + type: 'object', + properties: { + provider: { type: 'string', enum: ['gitlab', 'github'] }, + baseUrl: { type: 'string' }, + tokenEnv: { type: 'string' }, + }, + required: ['provider'], + additionalProperties: {}, + }, + labels: { key: 'host', value: 'forge' }, + }, + 'deck.apps': { + $schema: D, + type: 'object', + propertyNames: { type: 'string' }, + additionalProperties: { + type: 'object', + properties: { + published: { type: 'boolean' }, + publicFollowsOverride: { type: 'boolean' }, + passwordHash: { type: 'string' }, + passwordVersion: { type: 'number' }, + override: { + type: 'object', + properties: { + devPort: { type: 'number' }, + basePort: { type: 'number' }, + }, + required: ['devPort', 'basePort'], + additionalProperties: {}, + }, + }, + additionalProperties: {}, + }, + }, + 'board.members': { + $schema: D, + type: 'array', + items: { + type: 'object', + properties: { + username: { type: 'string' }, + name: { type: 'string' }, + hidden: { type: 'boolean' }, + agePublicKey: { type: 'string' }, + }, + required: ['username'], + additionalProperties: {}, + }, + }, +}; + +/** Keys whose registry def merges deep, so their wire def carries a layer + schema. */ +export const DEEP_KEYS = new Set([ + 'rt.homeSnapshot', + 'rt.runaway', + 'rt.cron', + 'rt.roles', + 'gitq.forges', + 'deck.apps', +]); + +/** rt-client's layerJsonSchema: every `required` dropped except inside + array items, because deep merge replaces arrays whole. */ +export function layerOf(schema: JsonSchema): JsonSchema { + return relax(schema, false) as JsonSchema; +} + +function relax(node: unknown, insideArray: boolean): unknown { + if (Array.isArray(node)) return node.map(n => relax(n, insideArray)); + if (typeof node !== 'object' || node === null) return node; + const out: Record = {}; + for (const [k, v] of Object.entries(node as Record)) { + if (k === 'required' && !insideArray) continue; + if (k === 'items' || k === 'prefixItems') out[k] = relax(v, true); + else if (k === 'properties') + out[k] = Object.fromEntries( + Object.entries(v as Record).map(([pk, pv]) => [ + pk, + relax(pv, insideArray), + ]) + ); + else out[k] = relax(v, insideArray); + } + return out; +} + +export function schemaFields( + key: string +): Pick { + const schema = TEST_SCHEMAS[key]; + if (!schema) return {}; + return DEEP_KEYS.has(key) + ? { schema, layerSchema: layerOf(schema) } + : { schema }; +} +``` + +Create `apps/console/src/app/settings/testSchemas.test.ts`: + +```ts +// @vitest-environment node +import { getDef } from '@mattstack/rt-client'; +import { describe, expect, it } from 'vitest'; + +import { DEEP_KEYS, schemaFields, TEST_SCHEMAS } from './testSchemas'; + +describe('test schemas', () => { + it.each(Object.keys(TEST_SCHEMAS))( + '%s matches the registry schema and layer schema', + key => { + const def = getDef(key); + expect(def).toBeDefined(); + expect(TEST_SCHEMAS[key]).toEqual(def!.schema); + expect(schemaFields(key).layerSchema).toEqual(def!.layerSchema); + expect(DEEP_KEYS.has(key)).toBe(def!.merge === 'deep'); + } + ); +}); +``` + +- [ ] **Step 5: Run the parity test** + +Run: `cd apps/console && bunx vitest run src/app/settings/testSchemas.test.ts && cd ../..` +Expected: PASS for every key. A failure means the registry changed after this plan was written: copy the registry's schema (print it with `bun -e "import { getDef } from '@mattstack/rt-client'; console.log(JSON.stringify(getDef('').schema, null, 2))"` from `apps/console`) into `TEST_SCHEMAS` and note the key in your report. + +- [ ] **Step 6: Update every test def factory** + +In each console test file listed under Files, the local `def(...)` factory (and any inline `SettingDefWire` literal the typecheck flags) gains `storeVersion: 1` and, before `...over`, `...schemaFields(key)`, importing `schemaFields` from `./testSchemas` (from `../settings/testSchemas` in `config/chain.test.ts`). For example `CompositeControls.test.tsx`'s factory becomes: + +```ts +function def(key: string, over: Partial): SettingDefWire { + return { + key, + type: 'array', + scopes: ['machine'], + merge: 'replace', + secret: false, + teamLocked: false, + repoScoped: false, + writable: true, + description: 'A composite.', + hasDefault: false, + defaultValue: null, + effective: { scope: null, file: null }, + storeVersion: 1, + ...schemaFields(key), + ...over, + }; +} +``` + +`CompositeControls.test.tsx` builds `rt.homeSnapshot` defs with `merge: 'replace'` in some cases; leave the `merge` overrides as they are (the test controls merge explicitly). In `apps/boxscore/src/app/settings/SettingsPage.test.tsx`, add `storeVersion: 1` only (boxscore moves to schemas in Task 3). + +- [ ] **Step 7: Run the console suite to see the behavioural failures** + +Run: `bun run console:test` +Expected: FAIL in `CompositeControls.test.tsx` and `SettingRow.test.tsx`: every composite editor renders read-only because `CompositeControls.tsx` still reads `SHAPES[def.key]`. + +- [ ] **Step 8: Read shapes from the schema in `CompositeControls.tsx`** + +Replace the settings-kit import block: + +```ts +import { + addToList, + getLeaf, + matchesSchema, + recognize, + summarize, + targetScope, + type LeafType, + type RowKind, +} from '@mattstack/settings-kit/shapes'; +``` + +`DeepShapeLock` loses its `shape` prop and checks each layer against the def's layer schema: + +```tsx +/** A deep key's merged value can fail its schema because of any layer, so + Clear targets the strongest layer whose own value fails, not the winner. */ +function DeepShapeLock({ def, row }: { def: SettingDefWire; row: Row }) { + const { rows, loading } = useSettingKey(def.key); + const bad = [...rows] + .reverse() + .find( + r => + r.present && + isStoreScope(r.scope) && + r.value !== undefined && + !matchesSchema(def, r.value) + ); + return ( + + ); +} +``` + +In `compositeParts`, replace everything from `const shape = SHAPES[def.key];` through the `if (kind === 'leaves' && shape.kind === 'leaves')` branch with: + +```tsx + const shape = recognize(def.schema); + const value = def.effective.value; + const toggle = ( + + ); + const readonly = + (value === undefined && !def.secret) || def.effective.scope === null + ? { control: , body: null } + : { control: toggle, body: open ? : null }; + + if ( + kind !== 'stringList' && + kind !== 'stringMap' && + kind !== 'leaves' + ) + return readonly; + + // An invalid winning layer arrives with no value; an editor seeded from + // nothing would discard whatever that layer stores on its first edit. + if ( + def.effective.invalid !== undefined || + (value !== undefined && !matchesSchema(def, value)) + ) { + return { + control: + def.merge === 'deep' && def.effective.invalid === undefined ? ( + + ) : ( + + ), + body: null, + }; + } + + if (kind === 'stringList') { + const list = strings(value); + if ( + list.length <= INLINE_MAX_ITEMS && + list.every(x => x.length <= INLINE_MAX_CHARS) + ) + return { + control: , + body: null, + }; + return { + control: toggle, + body: open ? : null, + }; + } + if (shape.kind === 'stringMap') + return { + control: toggle, + body: open ? ( + + ) : null, + }; + if (shape.kind === 'leaves') + return { + control: toggle, + body: open ? ( + + ) : null, + }; + return readonly; +``` + +(`kind` is `rowKind(def)`, which already returns `readonly` for secret and unwritable defs, so the old `kind === 'readonly' || !shape` guard is covered by the first `if`.) Remove the now-unused `CompositeShape` import and `SHAPES`/`matchesShape` references. `SettingRow.tsx` keeps `SHAPES[def.key]` for the external owner label: `SHAPES` still holds the external keys. + +- [ ] **Step 9: Keep the Editable count on today's editor kinds** + +In `view.ts`, import `type RowKind` from `@mattstack/settings-kit/shapes` and replace `isEditable`: + +```ts +/** Row kinds console draws an editor for. JSON kinds join as their editors + land; until then they render read-only and do not count as editable. */ +export const EDITOR_KINDS: ReadonlySet = new Set([ + 'scalar', + 'enum', + 'stringList', + 'stringMap', + 'leaves', +]); + +export function isEditable(def: SettingDefWire): boolean { + return EDITOR_KINDS.has(rowKind(def)); +} +``` + +- [ ] **Step 10: Run the console suite** + +Run: `bun run console:test` +Expected: PASS. If `an unshaped composite is read-only with a preview and its file` fails, check that `rt.cron`'s def carries its schema (from `schemaFields`) and recognizes as `json`. + +- [ ] **Step 11: Board: write the failing test for `shapeOf`** + +In `apps/board/src/client/__tests__/config-shapes.test.ts`, replace `COMPOSITE_SHAPES` in the import list with `shapeOf`, make the local `def` factory carry the registry schema: + +```ts +const REGISTRY = new Map(allDefs().map(d => [d.key, d])); + +function def(over: Partial & { key: string }): ConfigDef { + return { + type: 'string', + scopes: ['team'], + merge: 'replace', + secret: false, + teamLocked: false, + repoScoped: false, + writable: true, + description: '', + hasDefault: false, + defaultValue: undefined, + effective: { scope: null, file: null }, + storeVersion: 1, + schema: REGISTRY.get(over.key)?.schema, + ...over, + }; +} +``` + +and replace the whole `describe('COMPOSITE_SHAPES', ...)` block with: + +```ts +describe('shapeOf', () => { + test('gives every composite board.* registry key a board editor or a widget', () => { + const missing = allDefs() + .filter( + d => + d.key.startsWith('board.') && + (d.type === 'object' || d.type === 'array') && + !DELIBERATELY_READONLY_COMPOSITES.includes(d.key) + ) + .filter(d => shapeOf(d) === undefined) + .map(d => d.key); + expect(missing).toEqual([]); + }); + + test('previously shaped keys keep their widget, fields and fallbacks', () => { + expect(shapeOf(REGISTRY.get('board.projects')!)).toEqual({ + kind: 'stringList', + }); + expect(shapeOf(REGISTRY.get('board.cwds')!)).toEqual({ + kind: 'leaves', + fields: { review: 'string', respond: 'string', doctor: 'string' }, + fallbacks: {}, + }); + expect(shapeOf(REGISTRY.get('board.slack')!)).toMatchObject({ + kind: 'leaves', + fallbacks: { + 'emoji.looking': 'eyes', + 'emoji.commented': 'speech_balloon', + 'emoji.approved': 'white_check_mark', + }, + }); + expect(shapeOf(REGISTRY.get('board.tabs')!)).toEqual({ kind: 'tabs' }); + expect(shapeOf(REGISTRY.get('board.members')!)).toEqual({ + kind: 'roster', + }); + }); + + test('a schema board has no widget for is undefined', () => { + expect( + shapeOf({ key: 'board.mystery', schema: { type: 'object' } }) + ).toBeUndefined(); + expect(shapeOf({ key: 'board.mystery', schema: undefined })).toBeUndefined(); + }); +}); +``` + +If a `board.cwds` field list differs from `review`/`respond`/`doctor`, the test is right and the registry changed: stop and report it, since board's LeavesControl renders exactly those. + +Run: `bun run board:test` +Expected: FAIL, `shapeOf` is not exported. + +- [ ] **Step 12: Implement `shapeOf` in board** + +In `apps/board/src/client/board/config-shapes.ts`, add `recognize` to the `@mattstack/settings-kit/shapes` import, delete `COMPOSITE_SHAPES` and its doc comment, and add: + +```ts +/** A board composite row's editor: board's own for the keys settings-kit + marks `external`, else the widget matching the kind settings-kit + recognizes from the def's schema. Board has widgets for string lists and + leaves only; any other kind has no editor here. */ +export function shapeOf( + def: Pick +): CompositeShape | undefined { + const own = BOARD_EDITORS[def.key]; + if (own) return own; + const r = recognize(def.schema); + if (r.kind === 'stringList') return { kind: 'stringList' }; + if (r.kind === 'leaves') + return { kind: 'leaves', fields: r.fields, fallbacks: r.placeholders }; + return undefined; +} +``` + +and in `rowKind`, replace `const shape = COMPOSITE_SHAPES[def.key];` with `const shape = shapeOf(def);`. Remove `SHAPES` from the import if nothing else uses it. + +In `apps/board/src/client/board/ConfigModal.tsx`, import `shapeOf` instead of `COMPOSITE_SHAPES` and replace `const shape = COMPOSITE_SHAPES[def.key];` (the one in the row component near line 1058) with `const shape = shapeOf(def);`. `grep -n COMPOSITE_SHAPES apps/board/src` must print nothing afterwards. + +- [ ] **Step 13: Run every affected suite** + +Run each, bare: `bun run tui-kit:build`, `bun run board:typecheck`, `bun run board:test`, `bun run boxscore:typecheck`, `bun run boxscore:test`, `bun run deck:test`, `bun run chat:test` +Expected: PASS. rt-client 0.32.0's `setSetting` now refuses a schema-invalid value; if a deck, board or chat test fails because a write it makes is refused, do not change the test's value: stop and report the key, the value and the refusal (Task 2 is where writers are pinned against the schema). + +- [ ] **Step 14: Console gates** + +Run each, bare: `bun run console:typecheck`, `bun run console:lint`, `bun run console:test`, `sh scripts/repo-purity.sh`, `bun run format:check` +Expected: all exit 0 (run `bun run format` first if format:check flags only the files you touched). + +- [ ] **Step 15: UI validation** + +Follow the UI validation recipe. Checks, in both schemes, compared with the live page at `http://localhost:11001/settings`: `board.ticketPrefixes` (inline tags), `rt.repoIdentityOverrides` expanded (key/value rows, labels "remote URL" / "identity"), `board.slack` expanded (leaves with emoji placeholders), `rt.homeSnapshot` expanded (leaves with source badges). They must look identical to the live page. JSON keys such as `rt.notify.eventBridges` now show a row menu (they are writable under 0.4.0); that is expected. Board: run the live-data board on a spare port only if you changed its UI code beyond the three call sites; otherwise the board suite is the check. + +- [ ] **Step 16: Commit** + +```bash +git add package.json bun.lock apps/console/src/app/settings apps/console/src/app/config/chain.test.ts apps/board/src/client apps/boxscore/src/app/settings/SettingsPage.test.tsx +git commit -m "apps: rt-client 0.32.0, settings-kit 0.4.0; console and board read composite shapes from the schema + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 2: Pin every app writer against `validateWrite` + +**Needs from spec 1's wire contract:** rt-client `validateWrite(def, value, { scope, repoIdentity?, team? }): { ok: true } | { ok: false; reason; issues }` and `getDef(key)`. `validateWrite` reads the stores under `$HOME` for its merged-result check, so each test points `HOME` at an empty temp dir first. + +**Files:** +- Create: `apps/console/src/server/event-bridge-validate.test.ts` +- Modify: `apps/board/src/__tests__/config-store-latch.test.ts` +- Create: `apps/board/src/__tests__/writers-validate.test.ts` +- Modify: `apps/deck/core/settings.test.ts` +- Create: `apps/boxscore/src/app/settings/writers-validate.test.ts` + +**Interfaces:** +- Consumes: `installConsoleBridgeRule` (`apps/console/src/server/event-bridge.ts`), `boardBridgeRule` (`apps/board/src/gates/ingest.ts`), board's savers through `config-store-latch.test.ts`'s `fakeWrite`, deck's `setPublished`, `setPassword`, `clearPassword`, `setOverride`, `clearOverride`, `setPublicFollowsOverride`, `renameAppSettings` (`apps/deck/core/settings.ts`), boxscore's `asRosterEntries`. +- Produces: tests only. + +- [ ] **Step 1: Read the writers** + +Read `apps/board/src/gates/ingest.ts` lines 270-320, `apps/board/src/__tests__/config-store-latch.test.ts` lines 1-60 and `apps/deck/core/settings.test.ts` lines 1-70 and 253-275, so the edits below land in the right places. + +- [ ] **Step 2: Console bridge rule** + +`event-bridge.test.ts` mocks `@mattstack/rt-client` wholesale, so the real gate needs its own file. Create `apps/console/src/server/event-bridge-validate.test.ts`: + +```ts +// @vitest-environment node +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { EventBridgeRule } from '@mattstack/app-server/event-bridge'; +import { getDef, validateWrite } from '@mattstack/rt-client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { installConsoleBridgeRule } from './event-bridge'; + +let home: string; +const origHome = process.env.HOME; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'console-bridge-')); + process.env.HOME = home; +}); +afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); +}); + +const BOARD_RULE: EventBridgeRule = { + pattern: 'gate/opened/*', + subjectPrefix: 'mr:', + category: 'gate', + title: '{label}', + message: '{question}', + url: 'http://localhost:7930/gates/{id}', +}; + +describe('the reconciled console rule passes validateWrite', () => { + it('beside a board rule with deck answering, and alone with deck down', async () => { + const writes: EventBridgeRule[][] = []; + await installConsoleBridgeRule({ + read: () => [BOARD_RULE], + write: next => writes.push(next), + resolveUrl: async () => 'http://localhost:11001', + }); + await installConsoleBridgeRule({ + read: () => [], + write: next => writes.push(next), + resolveUrl: async () => null, + }); + expect(writes.length).toBe(2); + const def = getDef('rt.notify.eventBridges')!; + for (const value of writes) + expect(validateWrite(def, value, { scope: 'user' })).toEqual({ + ok: true, + }); + }); +}); +``` + +- [ ] **Step 3: Board writers** + +Board's savers already take an injectable `write`, and `apps/board/src/__tests__/config-store-latch.test.ts` drives every one of them (roster, tabs, hidden members, switchboard url) through its `fakeWrite` recorder, 30-odd calls. Make that recorder check each write against the gate rt-client's `setSetting` applies, so every existing latch test also pins its write. In that file: + +```ts +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; + +import { + getDef, + validateWrite, + type getSetting, + type setSetting, + type SettingScope, +} from '@mattstack/rt-client'; +``` + +(merge with the existing imports), add after the type aliases: + +```ts +// validateWrite's merged-result check reads the stores under HOME; an empty +// temp HOME keeps it from reading the real ones. +const origHome = process.env.HOME; +let home: string; +beforeAll(() => { + home = mkdtempSync(join(tmpdir(), 'board-latch-home-')); + process.env.HOME = home; +}); +afterAll(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); +}); +``` + +and replace `fakeWrite` with: + +```ts +/** Records every setSetting call instead of writing anywhere real, and + holds each one to the write gate setSetting applies. */ +function fakeWrite( + calls: Array<{ key: string; value: unknown; scope: string }> +): SetSettingFn { + return ((key: string, value: unknown, scope: string) => { + const def = getDef(key); + expect(def).toBeDefined(); + expect( + validateWrite(def!, value, { scope: scope as SettingScope }) + ).toEqual({ ok: true }); + calls.push({ key, value, scope }); + }) as SetSettingFn; +} +``` + +Then pin the gate rule board writes at boot by adding to `apps/board/src/__tests__/writers-validate.test.ts` (create): + +```ts +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { getDef, validateWrite } from '@mattstack/rt-client'; + +import { boardBridgeRule } from '../gates/ingest.ts'; + +let home: string; +const origHome = process.env.HOME; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'board-writers-')); + process.env.HOME = home; +}); +afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); +}); + +test('the gate bridge rule passes validateWrite alone and beside another rule', () => { + const def = getDef('rt.notify.eventBridges')!; + const rule = boardBridgeRule('http://localhost:7930'); + expect(validateWrite(def, [rule], { scope: 'user' })).toEqual({ ok: true }); + expect( + validateWrite( + def, + [ + rule, + { + pattern: 'gate/opened/*', + subjectPrefix: 'run:', + category: 'gate', + title: '{label}', + message: '{question}', + url: 'http://localhost:11001/gates/{id}', + owner: 'human', + }, + ], + { scope: 'user' } + ) + ).toEqual({ ok: true }); +}); +``` + +- [ ] **Step 4: Deck writers** + +deck's `save` writes `deck.apps` through rt-client's real `setSetting`, which in 0.32.0 runs `validateWrite` and throws on a refusal; `apps/deck/core/settings.test.ts` already points `HOME` and deck's settings file at temp paths per test. Add `validateWrite` and `getDef` to its `@mattstack/rt-client` import and append: + +```ts +test('store key present: every deck.apps write passes validateWrite', async () => { + setSetting( + 'deck.apps', + { 'acme-app': { published: true, publicFollowsOverride: false } }, + 'user' + ); + reloadSettings(); + + await setPublished('acme-app', false); + await setPassword('acme-app', 'correct horse'); + setOverride('acme-app', { devPort: 5173, basePort: 4100 }); + setPublicFollowsOverride('acme-app', true); + clearOverride('acme-app'); + renameAppSettings('acme-app', 'acme-web'); + await clearPassword('acme-web'); + + const stored = getSetting('deck.apps').value; + expect( + validateWrite(getDef('deck.apps')!, stored, { scope: 'user' }) + ).toEqual({ ok: true }); +}); +``` + +Each call above would throw from `setSetting` if its value were refused, so the test fails on the first bad write; the final assertion pins the resulting store value. If `renameAppSettings` or `setPublicFollowsOverride` is async in `settings.ts`, `await` it. + +- [ ] **Step 5: Boxscore writes** + +boxscore writes `mattstack.roster` (full array through `stage`/`apply`) and `boxscore.hiddenMembers` (a string list through `store.set`) from the client. Create `apps/boxscore/src/app/settings/writers-validate.test.ts`: + +```ts +// @vitest-environment node +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getDef, validateWrite } from '@mattstack/rt-client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { asRosterEntries } from './shapes'; + +let home: string; +const origHome = process.env.HOME; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'boxscore-writers-')); + process.env.HOME = home; +}); +afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); +}); + +describe('boxscore writes pass validateWrite', () => { + it('a roster built the way RosterRow builds it', () => { + const roster = asRosterEntries([{ username: 'rmarlow' }]); + const next = [...roster, { username: 'jdoe', name: 'J Doe' }]; + const def = getDef('mattstack.roster')!; + expect(validateWrite(def, next, { scope: 'team' })).toEqual({ ok: true }); + expect( + validateWrite(def, next.filter(e => e.username !== 'rmarlow'), { + scope: 'team', + }) + ).toEqual({ ok: true }); + }); + + it('hidden members', () => { + const def = getDef('boxscore.hiddenMembers')!; + expect(validateWrite(def, ['rmarlow'], { scope: 'user' })).toEqual({ + ok: true, + }); + expect(validateWrite(def, [], { scope: 'user' })).toEqual({ ok: true }); + }); +}); +``` + +If `boxscore.hiddenMembers`'s allowed scope is not `user`, use `getDef('boxscore.hiddenMembers')!.scopes[0]`. + +- [ ] **Step 6: Run them** + +Run each, bare: `bun run console:test`, `bun run board:test`, `bun run deck:test`, `bun run boxscore:test` +Expected: PASS. A refusal is a real finding (the app writes a value its own schema rejects): stop and report the key, value and `reason`; do not loosen the test. + +- [ ] **Step 7: Gates and commit** + +Run each, bare: `bun run console:typecheck`, `bun run console:lint`, `bun run console:test`, `bun run board:typecheck`, `bun run boxscore:typecheck`, `bun run boxscore:lint`, `sh scripts/repo-purity.sh`, `bun run format:check` + +```bash +git add apps/console/src/server/event-bridge-validate.test.ts apps/board/src/__tests__/config-store-latch.test.ts apps/board/src/__tests__/writers-validate.test.ts apps/deck/core/settings.test.ts apps/boxscore/src/app/settings/writers-validate.test.ts +git commit -m "apps: pin console, board, deck and boxscore settings writers against validateWrite + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- +### Task 3: Boxscore reads composite shapes from the schema + +boxscore never used settings-kit's `SHAPES`; it keeps its own `COMPOSITE_SHAPES` table. It moves to `recognize(def.schema)` with its existing widgets (`StringListControl`, `LeavesControl` for string and number leaves, its own roster editor for `mattstack.roster`), and every row renders as before. + +**Needs from spec 1's wire contract:** `SettingDefWire.schema` on every composite def from `/defs` (boxscore mounts `allowComposite: true`, and `defToWire` sends `schema` whenever the def has one); `recognize(schema)`, `matchesSchema(def, value)`. + +**Files:** +- Modify: `apps/boxscore/src/app/settings/shapes.ts` +- Create: `apps/boxscore/src/app/settings/shapes.test.ts` +- Modify: `apps/boxscore/src/app/settings/SettingsPage.tsx`, `apps/boxscore/src/app/settings/SettingsPage.test.tsx` + +**Interfaces:** +- Consumes: settings-kit `recognize`, `matchesSchema`. +- Produces: `shapeOf(def: Pick): CompositeShape | undefined` replacing `COMPOSITE_SHAPES`; `rowKind` and `matchesShape` keep their names; `matchesShape(def, value)` now takes the def. + +- [ ] **Step 1: Write the failing test** + +Create `apps/boxscore/src/app/settings/shapes.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; + +import type { ConfigDef } from './shapes'; +import { rowKind, shapeOf } from './shapes'; + +const STRING_LIST = { type: 'array', items: { type: 'string' } }; +const SIZE_BAND = { + type: 'object', + properties: { tooSmall: { type: 'number' }, tooLarge: { type: 'number' } }, + additionalProperties: {}, +}; +const ROSTER = { + type: 'array', + items: { + type: 'object', + properties: { username: { type: 'string' }, name: { type: 'string' } }, + required: ['username'], + additionalProperties: {}, + }, +}; + +function def(key: string, over: Partial = {}): ConfigDef { + return { + key, + type: 'array', + scopes: ['team'], + merge: 'replace', + secret: false, + teamLocked: false, + repoScoped: false, + writable: true, + description: '', + hasDefault: false, + defaultValue: null, + effective: { scope: null, file: null }, + storeVersion: 1, + ...over, + }; +} + +describe('shapeOf', () => { + it('a string array is a string list', () => { + expect(shapeOf(def('boxscore.projects', { schema: STRING_LIST }))).toEqual({ + kind: 'stringList', + }); + }); + + it('number leaves keep their fields', () => { + expect( + shapeOf(def('boxscore.sizeBand', { type: 'object', schema: SIZE_BAND })) + ).toEqual({ + kind: 'leaves', + fields: { tooSmall: 'number', tooLarge: 'number' }, + }); + }); + + it('the roster keeps its own editor whatever the schema says', () => { + expect(shapeOf(def('mattstack.roster', { schema: ROSTER }))).toEqual({ + kind: 'roster', + }); + }); + + it('leaves boxscore cannot draw, and unknown shapes, have no editor', () => { + expect( + shapeOf( + def('boxscore.flags', { + type: 'object', + schema: { + type: 'object', + properties: { on: { type: 'boolean' } }, + }, + }) + ) + ).toBeUndefined(); + expect(shapeOf(def('boxscore.mystery', { schema: undefined }))).toBeUndefined(); + expect(rowKind(def('boxscore.mystery', { schema: undefined }))).toBe( + 'readonly' + ); + }); +}); +``` + +Run: `bun run boxscore:test` +Expected: FAIL, `shapeOf` is not exported. + +- [ ] **Step 2: Implement** + +In `apps/boxscore/src/app/settings/shapes.ts`: add `import { matchesSchema, recognize } from '@mattstack/settings-kit/shapes';`, update the module comment's second sentence to "Composite rows take their editor from the kind settings-kit recognizes in the def's schema; boxscore draws string lists and string or number leaves, plus its own roster editor.", delete `COMPOSITE_SHAPES`, `matchesLeaf` and the old `matchesShape`, and add: + +```ts +/** Keys whose editor boxscore owns regardless of their schema. */ +const APP_EDITORS: Record = { + 'mattstack.roster': { kind: 'roster' }, +}; + +export function shapeOf( + def: Pick +): CompositeShape | undefined { + const own = APP_EDITORS[def.key]; + if (own) return own; + const r = recognize(def.schema); + if (r.kind === 'stringList') return { kind: 'stringList' }; + if (r.kind !== 'leaves') return undefined; + const fields: Record = {}; + for (const [path, type] of Object.entries(r.fields)) { + if (type !== 'string' && type !== 'number') return undefined; + fields[path] = type; + } + return { kind: 'leaves', fields }; +} + +/** Whether a stored value still fits the schema the control writes. A + mismatch (a hand-edited store file) renders read-only rather than a + control that would mangle the value on save. The roster is checked by + its own editor. */ +export function matchesShape(def: ConfigDef, value: unknown): boolean { + if (APP_EDITORS[def.key]?.kind === 'roster') return Array.isArray(value); + return def.schema === undefined || matchesSchema(def, value); +} +``` + +In `rowKind`, replace `const shape = COMPOSITE_SHAPES[def.key];` with `const shape = shapeOf(def);`. + +In `SettingsPage.tsx`, import `shapeOf` instead of `COMPOSITE_SHAPES`, and in `SettingRow` replace: + +```ts + const shape = COMPOSITE_SHAPES[def.key]; + const malformed = + shape !== undefined && value !== undefined && !matchesShape(shape, value); +``` + +with: + +```ts + const shape = shapeOf(def); + const malformed = + shape !== undefined && value !== undefined && !matchesShape(def, value); +``` + +- [ ] **Step 3: Give the page test's composite defs their schemas** + +In `SettingsPage.test.tsx`, add `const STRING_LIST = { type: 'array', items: { type: 'string' } };` under the imports and `schema: STRING_LIST` to the `boxscore.projects` and `boxscore.ignoredMrs` fixtures (lines near 85 and 95). The roster fixture needs none. + +- [ ] **Step 4: Run and gate** + +Run each, bare: `bun run boxscore:typecheck`, `bun run boxscore:lint`, `bun run boxscore:test`, `bun run console:typecheck`, `bun run console:lint`, `bun run console:test`, `sh scripts/repo-purity.sh`, `bun run format:check` +Expected: PASS. + +- [ ] **Step 5: UI validation** + +boxscore's page must look as it does today. Run `bun run build` in `apps/boxscore` and its server the way `apps/boxscore/package.json`'s `serve` (or `start`) script says, on a spare port if it lets you set one (check its `src/server/index.ts` for a `PORT` read; if the port is fixed and the live boxscore holds it, compare against the live app on its deck port instead and say so). Screenshot the settings page in both schemes with writes stubbed per the recipe (the route pattern is the same `/api/settings/(set|unset)`). `boxscore.projects` shows its tags input, `boxscore.sizeBand` its two number inputs, the roster its rows. + +- [ ] **Step 6: Commit** + +```bash +git add apps/boxscore/src/app/settings +git commit -m "boxscore: composite rows take their editor from the schema + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 4: Kit CodeMirror gains JSON schema completion and lint + +**Needs from spec 1's wire contract:** nothing directly. The kit takes any JSON Schema object for completion and a caller-supplied checker returning `{ path, message }[]` (the shape settings-kit's `checkValue` returns) for lint; it never imports settings-kit. + +**Files:** +- Modify: `packages/ui/package.json` +- Create: `packages/ui/src/lazy/codemirror/jsonSchema.ts`, `packages/ui/src/lazy/codemirror/jsonSchema.test.ts` +- Modify: `packages/ui/src/lazy/codemirror/CodeMirror.Base.tsx`, `packages/ui/src/lazy/codemirror/CodeMirror.tsx`, `packages/ui/src/lazy/index.ts`, `packages/ui/src/lazy/codemirror/CodeMirror.test.tsx`, `packages/ui/src/lazy/codemirror/CodeMirror.stories.tsx` + +**Interfaces:** +- Produces (exported from `@mattstack/app-kit/lazy`): + - `type JsonPathIssue = { path: (string | number)[]; message: string }` + - `type JsonSchemaCheck = (value: unknown) => JsonPathIssue[]` + - `CodeMirrorProps.jsonSchema?: Record` (completion of property names and enum/const/boolean values) + - `CodeMirrorProps.jsonCheck?: JsonSchemaCheck` (lint: a parse error, or each issue underlined at its path; `language="json"` only) +- Internal to the lazy chunk: `nodeAtPath(state, path)`, `jsonDiagnostics(state, check)`, `jsonSchemaCompletion(schema)`. + +- [ ] **Step 1: Add the CodeMirror packages the kit now uses directly** + +In `packages/ui/package.json` `dependencies`, add (alphabetically among the existing `@codemirror/*` entries): + +```json +"@codemirror/autocomplete": "^6.20.3", +"@codemirror/language": "^6.12.4", +"@codemirror/lint": "^6.9.7", +``` + +Run: `bun install` +Expected: exit 0; the three resolve to the versions already in `bun.lock` (they were transitive). + +- [ ] **Step 2: Write the failing unit tests** + +Create `packages/ui/src/lazy/codemirror/jsonSchema.test.ts`: + +```ts +import { CompletionContext } from '@codemirror/autocomplete'; +import { json } from '@codemirror/lang-json'; +import { ensureSyntaxTree } from '@codemirror/language'; +import { EditorState } from '@codemirror/state'; + +import { + jsonDiagnostics, + jsonSchemaCompletion, + nodeAtPath, +} from './jsonSchema'; + +const SCHEMA = { + type: 'array', + items: { + type: 'object', + properties: { + pattern: { type: 'string' }, + category: { type: 'string' }, + owner: { type: 'string', const: 'human' }, + provider: { enum: ['gitlab', 'github'] }, + }, + required: ['pattern'], + }, +}; + +function state(doc: string) { + const s = EditorState.create({ doc, extensions: [json()] }); + ensureSyntaxTree(s, doc.length); + return s; +} + +function complete(docWithCursor: string) { + const pos = docWithCursor.indexOf('|'); + const s = state(docWithCursor.replace('|', '')); + const r = jsonSchemaCompletion(SCHEMA)(new CompletionContext(s, pos, true)); + return r ? r.options.map(o => o.label) : null; +} + +describe('nodeAtPath', () => { + it('finds a property value inside an array item', () => { + const s = state('[{"pattern": 1}, {"category": "c"}]'); + const node = nodeAtPath(s, [0, 'pattern'])!; + expect(s.sliceDoc(node.from, node.to)).toBe('1'); + }); + + it('stops at the object when the property is missing', () => { + const s = state('[{"pattern": 1}, {"category": "c"}]'); + const node = nodeAtPath(s, [1, 'pattern'])!; + expect(node.name).toBe('Object'); + expect(node.from).toBe(17); + }); +}); + +describe('jsonDiagnostics', () => { + it('underlines each issue at its path; a container only at its bracket', () => { + const s = state('[{"pattern": 1}, {"category": "c"}]'); + const d = jsonDiagnostics(s, () => [ + { path: [0, 'pattern'], message: 'expected string, got number' }, + { path: [1, 'pattern'], message: 'required property "pattern" is missing' }, + ]); + expect(d.map(x => [x.from, x.to, x.message])).toEqual([ + [13, 14, 'expected string, got number'], + [17, 18, 'required property "pattern" is missing'], + ]); + }); + + it('reports a parse error once and never calls the checker', () => { + const check = vi.fn(() => []); + const d = jsonDiagnostics(state('[{'), check); + expect(d).toHaveLength(1); + expect(d[0]!.severity).toBe('error'); + expect(check).not.toHaveBeenCalled(); + }); + + it('an empty document has no diagnostics', () => { + expect( + jsonDiagnostics(state(' '), () => [{ path: [], message: 'x' }]) + ).toEqual([]); + }); +}); + +describe('jsonSchemaCompletion', () => { + it('offers the property names the object does not set yet', () => { + expect(complete('[{"pattern": "x", |}]')).toEqual([ + '"category"', + '"owner"', + '"provider"', + ]); + expect(complete('[{|}]')).toEqual([ + '"pattern"', + '"category"', + '"owner"', + '"provider"', + ]); + }); + + it('inside a half-typed name, offers every name but that property itself', () => { + expect(complete('[{"pattern": "x", "ca|"}]')).toEqual([ + '"category"', + '"owner"', + '"provider"', + ]); + }); + + it('offers enum and const values at a property value', () => { + expect(complete('[{"provider": |}]')).toEqual(['"gitlab"', '"github"']); + expect(complete('[{"owner": "|"}]')).toEqual(['"human"']); + }); + + it('replaces the whole quoted token, closing quote included', () => { + const doc = '[{"provider": "|"}]'; + const pos = doc.indexOf('|'); + const s = state(doc.replace('|', '')); + const r = jsonSchemaCompletion(SCHEMA)(new CompletionContext(s, pos, true))!; + expect([r.from, r.to]).toEqual([14, 16]); + }); + + it('has nothing to offer where the schema says nothing', () => { + expect(complete('[{"pattern": |}]')).toBeNull(); + }); +}); +``` + +(`describe`, `it`, `expect`, `vi` are vitest globals in `packages/ui`; if its vitest config does not enable globals, import them from `vitest`, matching `CodeMirror.test.tsx`.) + +Run: `bunx vitest run packages/ui/src/lazy/codemirror/jsonSchema.test.ts` +Expected: FAIL, `./jsonSchema` does not exist. + +- [ ] **Step 3: Implement `jsonSchema.ts`** + +Create `packages/ui/src/lazy/codemirror/jsonSchema.ts`: + +```ts +import type { + CompletionContext, + CompletionResult, +} from '@codemirror/autocomplete'; +import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'; +import type { Diagnostic } from '@codemirror/lint'; +import type { EditorState } from '@codemirror/state'; + +export type JsonPath = (string | number)[]; +export interface JsonPathIssue { + path: JsonPath; + message: string; +} +export type JsonSchemaCheck = (value: unknown) => JsonPathIssue[]; + +type Node = ReturnType['topNode']; +type Schema = Record; + +const VALUE_NODES = new Set([ + 'Object', + 'Array', + 'String', + 'Number', + 'True', + 'False', + 'Null', +]); + +function children(node: Node): Node[] { + const out: Node[] = []; + for (let c = node.firstChild; c; c = c.nextSibling) out.push(c); + return out; +} + +function propertyName(state: EditorState, prop: Node): string | null { + const name = prop.getChild('PropertyName'); + if (!name) return null; + try { + return JSON.parse(state.sliceDoc(name.from, name.to)) as string; + } catch { + return null; + } +} + +function valueOf(node: Node): Node | null { + return children(node).find(c => VALUE_NODES.has(c.name)) ?? null; +} + +/** The deepest syntax node the path reaches; a path naming a missing + property stops at the object that should hold it. */ +export function nodeAtPath(state: EditorState, path: JsonPath): Node | null { + const tree = ensureSyntaxTree(state, state.doc.length) ?? syntaxTree(state); + let node = valueOf(tree.topNode); + for (const seg of path) { + if (!node) return null; + let next: Node | null = null; + if (node.name === 'Object' && typeof seg === 'string') { + const prop = children(node).find( + c => c.name === 'Property' && propertyName(state, c) === seg + ); + next = prop ? valueOf(prop) : null; + } else if (node.name === 'Array' && typeof seg === 'number') { + next = children(node).filter(c => VALUE_NODES.has(c.name))[seg] ?? null; + } + if (!next) return node; + node = next; + } + return node; +} + +/** Containers underline only their opening bracket, so one bad property + does not paint the whole block. */ +function rangeOf(node: Node): { from: number; to: number } { + return node.name === 'Object' || node.name === 'Array' + ? { from: node.from, to: node.from + 1 } + : { from: node.from, to: node.to }; +} + +export function jsonDiagnostics( + state: EditorState, + check: JsonSchemaCheck +): Diagnostic[] { + const text = state.doc.toString(); + if (text.trim() === '') return []; + let value: unknown; + try { + value = JSON.parse(text); + } catch (err) { + return [ + { + from: 0, + to: Math.min(1, text.length), + severity: 'error', + message: (err as Error).message, + }, + ]; + } + return check(value).map(issue => { + const node = nodeAtPath(state, issue.path); + const { from, to } = node ? rangeOf(node) : { from: 0, to: 1 }; + return { from, to, severity: 'error', message: issue.message }; + }); +} + +function schemaFor(root: Schema, path: JsonPath): Schema | undefined { + let s: Schema | undefined = root; + for (const seg of path) { + if (!s) return undefined; + if (typeof seg === 'number') { + s = s.items as Schema | undefined; + } else { + const props = s.properties as Record | undefined; + const add = s.additionalProperties; + s = + props?.[seg] ?? + (add && typeof add === 'object' ? (add as Schema) : undefined); + } + } + return s; +} + +/** The JSON path of the object or array `node` is. */ +function pathOf(state: EditorState, node: Node): JsonPath { + const path: JsonPath = []; + let child = node; + for (let p = node.parent; p; child = p, p = p.parent) { + if (p.name === 'Property') { + const name = propertyName(state, p); + if (name !== null && child.name !== 'PropertyName') path.unshift(name); + } else if (p.name === 'Array') { + const items = children(p).filter(c => VALUE_NODES.has(c.name)); + const at = items.findIndex(c => c.from === child.from); + if (at >= 0) path.unshift(at); + } + } + return path; +} + +function nameTarget(node: Node): { obj: Node; own: Node | null } | null { + if (node.name === '{' && node.parent?.name === 'Object') + return { obj: node.parent, own: null }; + if (node.name === 'Object') return { obj: node, own: null }; + if (node.name === '⚠' && node.parent?.name === 'Object') + return { obj: node.parent, own: null }; + if (node.name === 'PropertyName' && node.parent?.parent?.name === 'Object') + return { obj: node.parent.parent, own: node.parent }; + return null; +} + +function valueTarget(node: Node): Node | null { + if (node.name === 'Property') return node; + return node.parent?.name === 'Property' && node.name !== 'PropertyName' + ? node.parent + : null; +} + +function enumValues(s: Schema | undefined): unknown[] { + if (!s) return []; + if (Array.isArray(s.enum)) return s.enum; + if ('const' in s) return [s.const]; + if (s.type === 'boolean') return [true, false]; + return []; +} + +/** Property names the schema allows at the cursor's object, and enum, + const or boolean values at a property's value. */ +export function jsonSchemaCompletion(schema: Schema) { + return (ctx: CompletionContext): CompletionResult | null => { + const node = syntaxTree(ctx.state).resolveInner(ctx.pos, -1); + const word = ctx.matchBefore(/"?[\w$-]*/); + // Inside a quoted name or string the whole token is replaced, closing + // quote included, or picking an option leaves a stray quote behind. + const quotedToken = node.name === 'PropertyName' || node.name === 'String'; + const from = quotedToken + ? node.from + : word && word.text !== '' + ? word.from + : ctx.pos; + const to = quotedToken ? node.to : undefined; + + const name = nameTarget(node); + if (name) { + const s = schemaFor(schema, pathOf(ctx.state, name.obj)); + const props = s?.properties as Record | undefined; + if (!props) return null; + const taken = new Set( + children(name.obj) + .filter(c => c.name === 'Property' && c !== name.own) + .map(c => propertyName(ctx.state, c)) + ); + const options = Object.keys(props) + .filter(k => !taken.has(k)) + .map(k => ({ label: JSON.stringify(k), type: 'property' })); + return options.length > 0 ? { from, to, options } : null; + } + + const prop = valueTarget(node); + const key = prop ? propertyName(ctx.state, prop) : null; + if (!prop?.parent || key === null) return null; + const values = enumValues( + schemaFor(schema, [...pathOf(ctx.state, prop.parent), key]) + ); + if (values.length === 0) return null; + return { + from, + to, + options: values.map(v => ({ label: JSON.stringify(v), type: 'enum' })), + }; + }; +} +``` + +Run: `bunx vitest run packages/ui/src/lazy/codemirror/jsonSchema.test.ts` +Expected: PASS (every case above was run against this exact module while writing the plan). + +- [ ] **Step 4: Write the failing component test** + +Append to `packages/ui/src/lazy/codemirror/CodeMirror.test.tsx`: + +```tsx +test('jsonCheck underlines schema issues, and a changed checker re-lints', async () => { + const { forEachDiagnostic, forceLinting } = await import('@codemirror/lint'); + const ref = createRef(); + const check = (value: unknown) => + Array.isArray(value) && typeof value[0] === 'number' + ? [{ path: [0], message: 'expected string, got number' }] + : []; + renderWithProviders( + + ); + await waitFor(() => expect(ref.current?.view).toBeTruthy()); + const view = ref.current!.view!; + forceLinting(view); + await waitFor(() => { + const found: string[] = []; + forEachDiagnostic(view.state, d => found.push(d.message)); + expect(found).toEqual(['expected string, got number']); + }); +}); + +test('without jsonCheck there is no linting at all', async () => { + const { diagnosticCount } = await import('@codemirror/lint'); + const ref = createRef(); + renderWithProviders(); + await waitFor(() => expect(ref.current?.view).toBeTruthy()); + expect(diagnosticCount(ref.current!.view!.state)).toBe(0); +}); +``` + +Run: `bunx vitest run packages/ui/src/lazy/codemirror/CodeMirror.test.tsx` +Expected: FAIL (TypeScript/props: `jsonCheck` unknown; the first test finds no diagnostics). + +- [ ] **Step 5: Wire the props into `CodeMirror.Base.tsx`** + +Add imports: + +```ts +import { autocompletion } from '@codemirror/autocomplete'; +import { linter, lintGutter } from '@codemirror/lint'; + +import { + jsonDiagnostics, + jsonSchemaCompletion, + type JsonSchemaCheck, +} from './jsonSchema'; + +export type { JsonPathIssue, JsonSchemaCheck } from './jsonSchema'; +``` + +Add to `CodeMirrorBaseProps`: + +```ts + /** A JSON Schema for `language="json"`: completes property names and + enum, const and boolean values. Reconfigures live. */ + jsonSchema?: Record; + /** Lints `language="json"`: a parse error, else each returned issue + underlined at its path. The caller supplies the checker so the kit + needs no validator and the messages match the caller's own. */ + jsonCheck?: JsonSchemaCheck; +``` + +Add a compartment beside the others (same pattern as `languageCompartment`) and a builder: + +```ts +function schemaExtensions( + language: CodeMirrorLanguage | undefined, + schema: Record | undefined, + check: JsonSchemaCheck | undefined +): Extension[] { + if (language !== 'json') return []; + const out: Extension[] = []; + if (schema) + out.push(autocompletion({ override: [jsonSchemaCompletion(schema)] })); + if (check) + out.push( + linter(view => jsonDiagnostics(view.state, check), { delay: 250 }), + lintGutter() + ); + return out; +} +``` + +In the mount effect's `allExtensions`, insert `schemaCompartment.of(schemaExtensions(language, jsonSchema, jsonCheck)),` right after the `languageCompartment.of(...)` line. Add an effect: + +```ts + // Reconfigure schema completion and lint when the schema, checker or + // language changes. + useEffect(() => { + viewRef.current?.dispatch({ + effects: schemaCompartment.reconfigure( + schemaExtensions(language, jsonSchema, jsonCheck) + ), + }); + }, [language, jsonSchema, jsonCheck, schemaCompartment]); +``` + +Destructure `jsonSchema` and `jsonCheck` from props alongside the others. In `CodeMirror.tsx`, re-export the types: `export type { JsonPathIssue, JsonSchemaCheck } from './CodeMirror.Base';` (type-only, so nothing CodeMirror-shaped leaves the lazy chunk). In `packages/ui/src/lazy/index.ts`, add `JsonPathIssue` and `JsonSchemaCheck` to the type exports from `./codemirror/CodeMirror`. + +- [ ] **Step 6: Story** + +Add to `CodeMirror.stories.tsx` a `JsonWithSchema` story rendering `` where `EVENT_BRIDGE_SCHEMA` is the `rt.notify.eventBridges` items schema from Task 1's fixture (inline it; the kit must not import console code) and `check` is a tiny local checker returning `[{ path: [0, 'pattern'], message: 'expected string, got number' }]` when `value[0].pattern` is not a string. Follow the file's existing `Meta`/`StoryObj` pattern. + +- [ ] **Step 7: Run and gate** + +Run each, bare: `bunx vitest run packages/ui/src/lazy`, `cd packages/ui && bun run typecheck && cd ../..`, `bun run lint`, `bun run treeshake`, `bun run console:typecheck`, `bun run console:lint`, `bun run console:test`, `sh scripts/repo-purity.sh`, `bun run format:check` +Expected: PASS. `treeshake` proves the new imports stayed behind the lazy boundary (only `CodeMirror.Base.tsx` and `jsonSchema.ts` import them). + +- [ ] **Step 8: UI validation** + +Run Storybook (`bun run storybook`), open `http://localhost:6006/?path=/story/` for `JsonWithSchema` with Fast Browser in both schemes: the `1` carries a red underline with the message on hover, typing `"` inside the object pops completions, and the lint gutter marker is visible and legible in dark. Stop Storybook afterwards. No settings writes are possible from Storybook, so the write stub is not needed; skip the bridge check. + +- [ ] **Step 9: Commit** + +```bash +git add packages/ui/package.json bun.lock packages/ui/src/lazy +git commit -m "kit: CodeMirror completes and lints JSON against a schema + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- +### Task 5: Console settings data layer: repo-aware store and explain hooks, layer rungs, writes that carry `repo` + +No visible change yet: the page still resolves with no repo (the picker lands in Task 6), but every read and write now goes through console's own hooks, a picked repo reaches every row through context, and a write's target (layer and repo) is computed in one place. + +**Needs from spec 1's wire contract:** `GET {base}/defs[?prefix=][&repo=]` answering `{ defs, unregistered: { key, scope, file }[] }`; `GET {base}/explain/:key[?repo=]` answering `{ def, rows }` with repo rungs `team.repo` / `user.repo` / `machine.repo` among the rows when `repo` is given; `GET {base}/repos` answering `{ repos: { identity, label }[] }`; `POST {base}/set` `{ key, scope, value, repo? }` and `POST {base}/unset` `{ key, scope, repo? }` answering `{ rows, effective }` or `{ error, issues? }` 400; `SettingDefWire.repoScoped`. From spec 3 (pinned above): `POST {base}/prune` `{ key, scope, repo?, storeName, force? }`. + +**Files:** +- Create: `apps/console/src/app/settings/useConsoleSettings.ts`, `apps/console/src/app/settings/useConsoleSettings.test.tsx` +- Modify: `apps/console/src/app/settings/view.ts`, `view.test.ts` +- Modify: `apps/console/src/app/settings/useRowSave.ts`, `ScopeBadge.tsx`, `RowMenu.tsx`, `CompositeControls.tsx`, `ExplainModal.tsx`, `SettingsPage.tsx`, `SettingRow.test.tsx` + +**Interfaces:** +- Consumes: settings-kit `useSettingsScope` (for `move` only), `targetScope`. +- Produces: + - `view.ts`: `type RungScope = 'team.repo' | 'user.repo' | 'machine.repo'`; `type LayerScope = StoreScope | RungScope`; `isRung(scope): scope is RungScope`; `rungBase(scope): StoreScope | null`; `rungOf(scope: StoreScope, repo: string | null): LayerScope`; `interface WriteTarget { scope: StoreScope; repo?: string }`; `writeTarget(def, repo: string | null): WriteTarget`; `targetAt(at: string, repo: string | null): WriteTarget | null`; `badgeScope` returns `LayerScope | null`; `fieldSource` returns `LayerScope | 'default' | null`. + - `useConsoleSettings.ts`: `interface Unregistered { key: string; scope: string; file: string }`; `interface RepoOption { identity: string; label: string }`; `interface ConsoleStore { defs; unregistered; loading; error; refresh(); set(key, scope, value, repo?); unset(key, scope, repo?); move(key, from, to); prune(key, scope, storeName, repo?) }` (every write resolves `string | null`); `useConsoleSettings(repo: string | null, prefix?: string): ConsoleStore`; `interface KeyExplain { def; rows; loading; error; refresh() }`; `useKeyExplain(key: string, repo: string | null): KeyExplain`; `useRepos(): { repos: RepoOption[]; error: string | null }`; `SettingsRepoContext` (`string | null`, default `null`); `useSettingsRepo(): string | null`. + - `useRowSave.ts`: `interface RowStore { set(key, scope, value, repo?); unset(key, scope, repo?); move(key, from, to) }`; the returned object gains `target: WriteTarget`; `setAt(at, value)` and `clear(at)` accept a rung (`team.repo`) and write the picked repo's section. + - `ScopeBadge({ scope }: { scope: LayerScope })`: a rung renders `team · repo` in its base scope's colour. + +- [ ] **Step 1: Write the failing view tests** + +Append to `view.test.ts` (import the new names from `./view`): + +```ts +describe('layer rungs and write targets', () => { + const REPO = 'gitlab.example.com/acme/app'; + const roles = (scope: string | null) => + def('rt.roles', { + type: 'object', + scopes: ['user', 'team', 'machine'], + repoScoped: true, + effective: { scope, file: '/t' }, + }); + + it('rungBase maps a repo rung to its store and passes store scopes through', () => { + expect(rungBase('team.repo')).toBe('team'); + expect(rungBase('machine')).toBe('machine'); + expect(rungBase('default')).toBeNull(); + expect(isRung('user.repo')).toBe(true); + expect(isRung('user')).toBe(false); + expect(rungOf('team', REPO)).toBe('team.repo'); + expect(rungOf('team', null)).toBe('team'); + }); + + it('with a repo picked, a repo-scoped key writes the repo section of its winning layer', () => { + expect(writeTarget(roles('team.repo'), REPO)).toEqual({ + scope: 'team', + repo: REPO, + }); + // A value inherited from the global team layer gets a repo override + // there, never a write to the global layer. + expect(writeTarget(roles('team'), REPO)).toEqual({ + scope: 'team', + repo: REPO, + }); + expect(writeTarget(roles(null), REPO)).toEqual({ + scope: 'user', + repo: REPO, + }); + }); + + it('without a repo, or for a key that is not repo-scoped, the target has no repo', () => { + expect(writeTarget(roles('team'), null)).toEqual({ scope: 'team' }); + expect( + writeTarget( + def('rt.logLevel', { + scopes: ['machine'], + effective: { scope: 'machine', file: '/m', value: 'info' }, + }), + REPO + ) + ).toEqual({ scope: 'machine' }); + }); + + it('targetAt writes a rung only when a repo is picked', () => { + expect(targetAt('team.repo', REPO)).toEqual({ scope: 'team', repo: REPO }); + expect(targetAt('team.repo', null)).toBeNull(); + expect(targetAt('user', REPO)).toEqual({ scope: 'user' }); + expect(targetAt('default', REPO)).toBeNull(); + }); + + it('badgeScope always shows a repo rung, even under a matching subhead', () => { + expect(badgeScope(roles('team.repo'), 'team')).toBe('team.repo'); + }); + + it('the scope filter matches a repo rung by its store', () => { + const f = { ...NO_FILTER, scope: 'team' as const }; + expect(applyFilter([roles('team.repo')], f)).toHaveLength(1); + }); +}); +``` + +(`def` is the file's existing factory; if its signature differs, adapt the calls, not the assertions.) + +Run: `cd apps/console && bunx vitest run src/app/settings/view.test.ts && cd ../..` +Expected: FAIL, the new names are not exported. + +- [ ] **Step 2: Implement the rung helpers in `view.ts`** + +Add `targetScope` to the `@mattstack/settings-kit/shapes` import and, after `isStoreScope`: + +```ts +export type RungScope = 'team.repo' | 'user.repo' | 'machine.repo'; +/** A store layer, or a store's section for the picked repo. */ +export type LayerScope = StoreScope | RungScope; + +export function isRung(s: string | null | undefined): s is RungScope { + return s === 'team.repo' || s === 'user.repo' || s === 'machine.repo'; +} + +/** The store a layer lives in: `team.repo` is the team store's repo + section. */ +export function rungBase(s: string | null | undefined): StoreScope | null { + if (isStoreScope(s)) return s; + return isRung(s) ? (s.slice(0, -'.repo'.length) as StoreScope) : null; +} + +export function rungOf(scope: StoreScope, repo: string | null): LayerScope { + return repo ? (`${scope}.repo` as RungScope) : scope; +} + +export interface WriteTarget { + scope: StoreScope; + repo?: string; +} + +/** Where an edit of `def` lands. With a repo picked, a repo-scoped key + writes that repo's section of the layer its value comes from, so a value + inherited from a global layer gets a repo override rather than a global + write; with no allowed winning layer, the key's first scope. */ +export function writeTarget( + def: SettingDefWire, + repo: string | null +): WriteTarget { + if (def.repoScoped && repo) { + const base = rungBase(def.effective.scope); + const scope = + base && (def.scopes as readonly string[]).includes(base) + ? base + : (def.scopes[0] as StoreScope); + return { scope, repo }; + } + return { scope: targetScope(def) as StoreScope }; +} + +/** A layer line's scope as a write target; a repo rung needs the picked + repo. */ +export function targetAt(at: string, repo: string | null): WriteTarget | null { + const scope = rungBase(at); + if (!scope) return null; + if (!isRung(at)) return { scope }; + return repo ? { scope, repo } : null; +} +``` + +Change `applyFilter`'s scope clause to `(f.scope === 'any' || rungBase(d.effective.scope) === f.scope)`. Change `badgeScope`: + +```ts +export function badgeScope( + def: SettingDefWire, + subhead: StoreScope | null +): LayerScope | null { + const scope = def.effective.scope; + if (isRung(scope)) return scope; + return isStoreScope(scope) && scope !== subhead ? scope : null; +} +``` + +and `fieldSource`'s return type to `LayerScope | 'default' | null`, with its condition `if (r.scope === 'default' || isStoreScope(r.scope) || isRung(r.scope)) return r.scope;`. + +Run: `cd apps/console && bunx vitest run src/app/settings/view.test.ts && cd ../..` +Expected: PASS. + +- [ ] **Step 3: Write the failing hook tests** + +Create `apps/console/src/app/settings/useConsoleSettings.test.tsx`: + +```tsx +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useConsoleSettings, useKeyExplain } from './useConsoleSettings'; + +const REPO = 'gitlab.example.com/acme/app'; +type Call = { url: string; body?: Record }; +let calls: Call[]; + +beforeEach(() => { + calls = []; + vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => { + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ url, body }); + if (url.includes('/defs')) + return { + ok: true, + status: 200, + json: async () => ({ + defs: [], + unregistered: [ + { + key: 'board.rtRepos', + scope: 'machine', + file: '/home/user/local/settings.local.jsonc', + }, + ], + }), + }; + if (url.includes('/explain/')) + return { + ok: true, + status: 200, + json: async () => ({ def: null, rows: [] }), + }; + return { + ok: true, + status: 200, + json: async () => ({ rows: [], effective: { scope: 'team.repo', file: '/t' } }), + }; + }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('useConsoleSettings', () => { + it('reads defs for the picked repo and keeps the unregistered list', async () => { + const { result } = renderHook(() => useConsoleSettings(REPO)); + await waitFor(() => expect(result.current.loading).toBe(false)); + const defsCall = calls.find( + c => c.url.includes('/defs') && !c.url.includes('console.move-only') + )!; + expect(new URL(defsCall.url, 'http://x').searchParams.get('repo')).toBe( + REPO + ); + expect(result.current.unregistered).toEqual([ + { + key: 'board.rtRepos', + scope: 'machine', + file: '/home/user/local/settings.local.jsonc', + }, + ]); + }); + + it('sends repo in a write body only when one is given', async () => { + const { result } = renderHook(() => useConsoleSettings(null)); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.set('rt.roles', 'team', { dev: {} }, REPO); + await result.current.set('rt.logLevel', 'machine', 'info'); + await result.current.unset('rt.roles', 'team', REPO); + await result.current.prune('rt.roles', 'team', 'rt.roles', REPO); + }); + const writes = calls.filter(c => c.body); + expect(writes.map(c => [c.url, c.body])).toEqual([ + [ + '/api/settings/set', + { key: 'rt.roles', scope: 'team', value: { dev: {} }, repo: REPO }, + ], + [ + '/api/settings/set', + { key: 'rt.logLevel', scope: 'machine', value: 'info' }, + ], + ['/api/settings/unset', { key: 'rt.roles', scope: 'team', repo: REPO }], + [ + '/api/settings/prune', + { + key: 'rt.roles', + scope: 'team', + storeName: 'rt.roles', + force: true, + repo: REPO, + }, + ], + ]); + }); + + it('re-reads defs after a write without raising loading', async () => { + const seen: boolean[] = []; + const { result } = renderHook(() => { + const store = useConsoleSettings(null); + seen.push(store.loading); + return store; + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + const ours = () => + calls.filter( + c => + c.url.startsWith('/api/settings/defs') && + !c.url.includes('console.move-only') + ).length; + const before = ours(); + const settled = seen.length; + await act(async () => { + await result.current.set('rt.logLevel', 'machine', 'info'); + }); + await waitFor(() => expect(ours()).toBe(before + 1)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(seen.slice(settled)).not.toContain(true); + }); +}); + +describe('useKeyExplain', () => { + it('asks for the picked repo', async () => { + const { result } = renderHook(() => useKeyExplain('rt.roles', REPO)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(calls.at(-1)!.url).toBe( + `/api/settings/explain/rt.roles?repo=${encodeURIComponent(REPO)}` + ); + }); +}); +``` + +Run: `cd apps/console && bunx vitest run src/app/settings/useConsoleSettings.test.tsx && cd ../..` +Expected: FAIL, the module does not exist. + +- [ ] **Step 4: Implement `useConsoleSettings.ts`** + +```ts +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + useSettingsScope, + type EffectiveWire, + type ExplainRowWire, + type SettingDefWire, +} from '@mattstack/settings-kit/react'; + +const BASE = '/api/settings'; +// Matches no registered key: that scope instance exists only to lend +// settings-kit's move, which is not exported on its own. +const MOVE_ONLY_PREFIX = 'console.move-only.'; + +export interface Unregistered { + key: string; + scope: string; + file: string; +} + +export interface RepoOption { + identity: string; + label: string; +} + +type Write = Promise; + +export interface ConsoleStore { + defs: SettingDefWire[]; + unregistered: Unregistered[]; + loading: boolean; + error: string | null; + refresh: () => void; + set: (key: string, scope: string, value: unknown, repo?: string) => Write; + unset: (key: string, scope: string, repo?: string) => Write; + move: (key: string, from: string, to: string) => Write; + prune: (key: string, scope: string, storeName: string, repo?: string) => Write; +} + +export interface KeyExplain { + def: SettingDefWire | null; + rows: ExplainRowWire[]; + loading: boolean; + error: string | null; + refresh: () => void; +} + +/** The repo picked on /settings, or null for all repos. */ +export const SettingsRepoContext = createContext(null); + +export function useSettingsRepo(): string | null { + return useContext(SettingsRepoContext); +} + +async function getJson(url: string): Promise { + const res = await fetch(url); + const body = (await res.json().catch(() => null)) as + | (T & { error?: string }) + | null; + if (!res.ok) + throw new Error(body?.error ?? `settings request failed: ${res.status}`); + if (body === null) throw new Error('settings response was not JSON'); + return body; +} + +function query(params: Record): string { + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) if (v) q.set(k, v); + const s = q.toString(); + return s ? `?${s}` : ''; +} + +/** Every registered def under `prefix`, resolved for `repo` when one is + picked. A write patches its def at once and then re-reads the list + without raising `loading`, since it can change the def's issues and the + repos that set it. */ +export function useConsoleSettings( + repo: string | null, + prefix = '' +): ConsoleStore { + const [defs, setDefs] = useState([]); + const [unregistered, setUnregistered] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [generation, setGeneration] = useState(0); + const quiet = useRef(false); + const kit = useSettingsScope(MOVE_ONLY_PREFIX); + + useEffect(() => { + let alive = true; + if (!quiet.current) setLoading(true); + quiet.current = false; + getJson<{ defs: SettingDefWire[]; unregistered?: Unregistered[] }>( + `${BASE}/defs${query({ prefix, repo })}` + ) + .then(body => { + if (!alive) return; + setDefs(body.defs); + setUnregistered(body.unregistered ?? []); + setError(null); + }) + .catch((err: Error) => { + if (alive) setError(err.message); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [prefix, repo, generation]); + + const refresh = useCallback(() => setGeneration(g => g + 1), []); + const reread = useCallback(() => { + quiet.current = true; + setGeneration(g => g + 1); + }, []); + + const post = useCallback( + async ( + path: 'set' | 'unset' | 'prune', + key: string, + body: Record + ): Write => { + try { + const res = await fetch(`${BASE}/${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ key, ...body }), + }); + const out = (await res.json().catch(() => null)) as { + effective?: EffectiveWire; + error?: string; + } | null; + if (!res.ok || !out?.effective) + return out?.error ?? `${path} failed: ${res.status}`; + const effective = out.effective; + setDefs(prev => + prev.map(d => (d.key === key ? { ...d, effective } : d)) + ); + reread(); + return null; + } catch (err) { + return (err as Error).message; + } + }, + [reread] + ); + + const withRepo = (r?: string) => (r ? { repo: r } : {}); + const set = useCallback( + (key: string, scope: string, value: unknown, r?: string) => + post('set', key, { scope, value, ...withRepo(r) }), + [post] + ); + const unset = useCallback( + (key: string, scope: string, r?: string) => + post('unset', key, { scope, ...withRepo(r) }), + [post] + ); + const prune = useCallback( + (key: string, scope: string, storeName: string, r?: string) => + post('prune', key, { scope, storeName, force: true, ...withRepo(r) }), + [post] + ); + const { move: kitMove } = kit; + const move = useCallback( + async (key: string, from: string, to: string) => { + const err = await kitMove(key, from, to); + reread(); + return err; + }, + [kitMove, reread] + ); + + return useMemo( + () => ({ + defs, + unregistered, + loading, + error, + refresh, + set, + unset, + move, + prune, + }), + [defs, unregistered, loading, error, refresh, set, unset, move, prune] + ); +} + +/** One key's layer stack, with the picked repo's rungs when one is given. */ +export function useKeyExplain(key: string, repo: string | null): KeyExplain { + const [def, setDef] = useState(null); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [generation, setGeneration] = useState(0); + + useEffect(() => { + let alive = true; + setLoading(true); + getJson<{ def: SettingDefWire; rows: ExplainRowWire[] }>( + `${BASE}/explain/${encodeURIComponent(key)}${query({ repo })}` + ) + .then(body => { + if (!alive) return; + setDef(body.def); + setRows(body.rows); + setError(null); + }) + .catch((err: Error) => { + if (alive) setError(err.message); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [key, repo, generation]); + + const refresh = useCallback(() => setGeneration(g => g + 1), []); + return useMemo( + () => ({ def, rows, loading, error, refresh }), + [def, rows, loading, error, refresh] + ); +} + +export function useRepos(): { repos: RepoOption[]; error: string | null } { + const [repos, setRepos] = useState([]); + const [error, setError] = useState(null); + useEffect(() => { + let alive = true; + getJson<{ repos: RepoOption[] }>(`${BASE}/repos`) + .then(body => alive && setRepos(body.repos)) + .catch((err: Error) => alive && setError(err.message)); + return () => { + alive = false; + }; + }, []); + return { repos, error }; +} +``` + +Run: `cd apps/console && bunx vitest run src/app/settings/useConsoleSettings.test.tsx && cd ../..` +Expected: PASS. + +- [ ] **Step 5: Write the failing row-save and row-menu tests** + +Append to `SettingRow.test.tsx` (import `SettingsRepoContext` from `./useConsoleSettings`): + +```tsx +describe('with a repo picked', () => { + const REPO = 'gitlab.example.com/acme/app'; + const inRepo = (ui: React.ReactElement) => + renderWithProviders( + {ui} + ); + + it('an edit of a repo-scoped key inherited from a global layer writes a repo override', async () => { + const s = store(); + inRepo( + + ); + const input = screen.getByLabelText('rt.worktreeCwd'); + await userEvent.clear(input); + await userEvent.type(input, 'b'); + await userEvent.tab(); + await waitFor(() => + expect(s.set).toHaveBeenCalledWith('rt.worktreeCwd', 'team', 'b', REPO) + ); + }); + + it('a key that is not repo-scoped writes as before, with no repo argument', async () => { + const s = store(); + inRepo( + + ); + const input = screen.getByLabelText('agent.claude.effort'); + await userEvent.clear(input); + await userEvent.type(input, 'low'); + await userEvent.tab(); + await waitFor(() => + expect(s.set).toHaveBeenCalledWith('agent.claude.effort', 'user', 'low') + ); + }); + + it('a value from a repo rung can be removed but not moved', async () => { + const s = store(); + inRepo( + + ); + expect(screen.getByText('team · repo')).toBeInTheDocument(); + await userEvent.click( + screen.getByRole('button', { name: 'rt.worktreeCwd actions' }) + ); + expect(screen.queryByRole('menuitem', { name: /^Move to/ })).toBeNull(); + await userEvent.click( + await screen.findByRole('menuitem', { name: 'Remove from team · repo' }) + ); + await waitFor(() => + expect(s.unset).toHaveBeenCalledWith('rt.worktreeCwd', 'team', REPO) + ); + }); +}); +``` + +`rt.worktreeCwd` is a stand-in string key for the fixture; any registered or unregistered string key works because the def is built in the test. + +Run: `bun run console:test` +Expected: FAIL on the three new tests. + +- [ ] **Step 6: Route every write through the target** + +Replace `useRowSave.ts` with: + +```ts +import { useEffect, useRef, useState } from 'react'; +import type { SettingDefWire } from '@mattstack/settings-kit/react'; + +import { useSettingsRepo, type ConsoleStore } from './useConsoleSettings'; +import { targetAt, writeTarget, type WriteTarget } from './view'; + +export type RowStore = Pick; +export type SaveStatus = 'idle' | 'saving' | 'saved'; + +const SAVED_FLASH_MS = 1400; + +/** Save-on-commit, no staging: `undefined` means "clear this layer". A + write with no repo passes no repo argument at all. */ +export function useRowSave(store: RowStore, def: SettingDefWire) { + const repo = useSettingsRepo(); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(null); + const timer = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(timer.current), []); + + const run = async (op: () => Promise) => { + setStatus('saving'); + setError(null); + const err = await op(); + if (err) { + setStatus('idle'); + setError(err); + return false; + } + setStatus('saved'); + clearTimeout(timer.current); + timer.current = setTimeout(() => setStatus('idle'), SAVED_FLASH_MS); + return true; + }; + + const setTo = (t: WriteTarget, value: unknown) => + t.repo === undefined + ? store.set(def.key, t.scope, value) + : store.set(def.key, t.scope, value, t.repo); + const unsetAt = (t: WriteTarget) => + t.repo === undefined + ? store.unset(def.key, t.scope) + : store.unset(def.key, t.scope, t.repo); + const at = (layer: string) => { + const t = targetAt(layer, repo); + return t ?? `${layer} is not a writable layer here`; + }; + + const target = writeTarget(def, repo); + return { + status, + error, + target, + save: (value: unknown) => + run(() => (value === undefined ? unsetAt(target) : setTo(target, value))), + setAt: (layer: string, value: unknown) => + run(async () => { + const t = at(layer); + return typeof t === 'string' ? t : setTo(t, value); + }), + clear: (layer: string) => + run(async () => { + const t = at(layer); + return typeof t === 'string' ? t : unsetAt(t); + }), + move: (from: string, to: string) => + run(() => store.move(def.key, from, to)), + }; +} +``` + +`ScopeBadge.tsx`: change both components' prop to `LayerScope` (import `rungBase`, `isRung`, `type LayerScope` from `./view`); colour from `SCOPE_COLOR[rungBase(scope)!]`; the badge text is `isRung(scope) ? `${rungBase(scope)} · repo` : scope`. `ScopeDot` keeps `StoreScope`. + +`RowMenu.tsx`: import `isRung`, `rungBase`, `type LayerScope`; replace the guard and `moveTo` with: + +```ts + const from = def.effective.scope; + const base = rungBase(from); + if (!def.writable || !base || !def.scopes.includes(base)) + return ; + // A move re-sets the value at its target, which rejects what rt already + // refused here; removing it still works. settings-kit's move reads and + // writes global layers only, so a repo rung offers removal alone. + const moveTo = + def.effective.invalid === undefined && !isRung(from) + ? (def.scopes as StoreScope[]).filter(s => s !== from && isStoreScope(s)) + : []; + const label = isRung(from) ? `${base} · repo` : from; +``` + +the Remove item's text becomes `{`Remove from ${label}`}` and its `onClick` stays `row.clear(from!)` (typed as `from as LayerScope`). + +`CompositeControls.tsx`: replace `useSettingKey(def.key)` (in `LeavesBody` and `DeepShapeLock`) with `useKeyExplain(def.key, repo)` where `const repo = useSettingsRepo();`, drop `useSettingKey` and `targetScope` from the imports, and in `LeavesBody` replace `const target = targetScope(def);` with: + +```ts + const target = rungOf(row.target.scope, row.target.repo ?? null); +``` + +(`rungOf` from `./view`); `leafWrite(explained.rows, target, path, v)`, `row.clear(target)` and the `source !== target` check keep their shape. `DeepShapeLock` also accepts a rung as `at` when checking rows: replace `isStoreScope(r.scope)` there with `rungBase(r.scope) !== null`, and `ShapeLock`'s `isStoreScope(at)` checks with `rungBase(at) !== null`. + +`ExplainModal.tsx`: `ExplainStore` becomes `Pick`; `ExplainBody` uses `useKeyExplain(storeDef.key, useSettingsRepo())`; the `tracked` store forwards every argument (`set: async (...a) => after(await store.set(...a))` already does, as long as its type is `RowStore`); `OwnStore` uses `useConsoleSettings(null, props.settingKey)`. `LayerLine`'s `store` becomes `rungBase(scope)` for the allowed/writable checks, its `onSet`/`onRemove` receive the row's own `scope` string (a rung included) and the modal passes them to `layers.setAt(scope, v)` / `layers.clear(scope)`; the `ScopeBadge` there receives `scope as LayerScope` when `rungBase(scope)` is non-null. Remove the `useSettingKey` and `useSettingsScope` imports. + +`SettingsPage.tsx`: `const repo = params.get('repo');` next to `query`, `const store = useConsoleSettings(repo);` instead of `useSettingsScope('')`, and wrap the returned `` in ``. + +Run: `bun run console:test` +Expected: PASS, including every pre-existing test (writes with no repo still pass exactly three arguments). + +- [ ] **Step 7: Gates and commit** + +Run each, bare: `bun run console:typecheck`, `bun run console:lint`, `bun run console:test`, `sh scripts/repo-purity.sh`, `bun run format:check` + +No UI validation for this task beyond a smoke check: follow the recipe once in light only and confirm `/settings` renders its sections and the explain modal opens for `rt.homeSnapshot` (nothing should look different from the live page). Record that in the report. + +```bash +git add apps/console/src/app/settings +git commit -m "console: repo-aware settings hooks, layer rungs, writes carry repo + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- +### Task 6: Repo picker, repo reach on rows, repos in the explain modal + +**Needs from spec 1's wire contract:** `GET {base}/repos` (`{ repos: { identity, label }[] }`); `/defs?repo=` resolving repo-scoped keys for that repo with `effective.scope` a rung (`team.repo`) when a repo section wins; `SettingDefWire.repos?: { identity: string; scopes: string[] }[]` on repo-scoped defs (present with or without `?repo=`); `/explain/:key?repo=` rows including the rungs. + +**Files:** +- Create: `apps/console/src/app/settings/RepoPicker.tsx`, `apps/console/src/app/settings/RepoReach.tsx` +- Modify: `apps/console/src/app/settings/view.ts` (`repoLabel`), `SettingsPage.tsx`, `SettingRow.tsx`, `ExplainModal.tsx`, `useConsoleSettings.ts` (`useRepos` tolerates a body without `repos`) +- Test: `SettingsPage.test.tsx`, `SettingRow.test.tsx`, `ExplainModal.test.tsx` + +**Interfaces:** +- Consumes: `useRepos`, `useSettingsRepo`, `useKeyExplain`, `SettingsRepoContext` (Task 5). +- Produces: + - `view.ts`: `repoLabel(identity: string): string` (the identity after its first `/`, the server's label rule). + - `RepoPicker({ value, onChange }: { value: string | null; onChange: (repo: string | null) => void })`. + - `RepoReach({ def }: { def: SettingDefWire })`: renders nothing for a key that is not repo-scoped. + - `ExplainModal` gains `onPickRepo?: (repo: string) => void`; with it and no repo picked, a repo-scoped key lists every repo that sets it. + +- [ ] **Step 1: Write the failing tests** + +`SettingsPage.test.tsx`: replace the `beforeEach` fetch stub with a router so `/repos` and repo-resolved defs can differ: + +```ts +const REPO = 'gitlab.example.com/acme/app'; +let repoDefs: SettingDefWire[] | null = null; + +beforeEach(() => { + defsResponse = serve(DEFS); + repoDefs = null; + window.history.replaceState(null, '', '/settings'); + vi.stubGlobal('fetch', async (url: string) => { + if (url.startsWith('/api/settings/repos')) + return { + ok: true, + status: 200, + json: async () => ({ + repos: [ + { identity: REPO, label: 'acme/app' }, + { identity: 'gitlab.example.com/acme/web', label: 'acme/web' }, + ], + }), + }; + if (repoDefs && url.includes(`repo=${encodeURIComponent(REPO)}`)) + return serve(repoDefs)(); + return defsResponse(); + }); +}); +``` + +and add: + +```ts +describe('repo picker', () => { + const ROLES = (effective: SettingDefWire['effective']) => + def('rt.roles', { + type: 'object', + scopes: ['user', 'team', 'machine'], + merge: 'deep', + repoScoped: true, + repos: [{ identity: REPO, scopes: ['team'] }], + effective, + }); + + it('lists All repos plus each repo, and picking one keeps it in ?repo=', async () => { + defsResponse = serve([...DEFS, ROLES({ scope: null, file: null })]); + repoDefs = [ + ...DEFS, + ROLES({ scope: 'team.repo', file: '/home/team/settings.team.jsonc', value: { dev: { fixedPort: 3000 } } }), + ]; + renderPage(); + await screen.findByRole('heading', { name: 'Board' }); + expect(screen.getByText('all repos · set in 1 repo')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('textbox', { name: 'repo' })); + await userEvent.click(await screen.findByRole('option', { name: 'acme/app' })); + await waitFor(() => + expect(new URLSearchParams(window.location.search).get('repo')).toBe(REPO) + ); + expect(await screen.findByText('team · repo')).toBeInTheDocument(); + expect(screen.getByText('for acme/app')).toBeInTheDocument(); + }); + + it('the Changed count follows the picked repo', async () => { + defsResponse = serve([...DEFS, ROLES({ scope: null, file: null })]); + repoDefs = [ + ...DEFS, + ROLES({ scope: 'team.repo', file: '/home/team/settings.team.jsonc', value: {} }), + ]; + window.history.replaceState(null, '', `/settings?repo=${encodeURIComponent(REPO)}`); + renderPage(); + const changed = await screen.findByRole('checkbox', { name: /^Changed/ }); + const count = (n: number) => + expect(changed.closest('label') ?? changed.parentElement!).toHaveTextContent(`Changed ${n}`); + await waitFor(() => count(DEFS.filter(d => d.effective.scope !== null && d.effective.scope !== 'default').length + 1)); + }); +}); +``` + +(Mantine's `Select` input has `role="textbox"` in 9.x when not searchable; if `getByRole('textbox', { name: 'repo' })` finds nothing, check the rendered role with `screen.debug()` and use `getByLabelText('repo')` instead. Mantine's `Chip` renders a checkbox input; adjust the `Changed` lookup to how the existing tests in this file find the chip if they already do.) + +`SettingRow.test.tsx`, append: + +```tsx +it('a repo-scoped row says it edits all repos and how many repos set it', () => { + renderWithProviders( + + ); + expect(screen.getByText('all repos · set in 2 repos')).toBeInTheDocument(); +}); + +it('a key that is not repo-scoped says nothing about repos', () => { + renderWithProviders( + + ); + expect(screen.queryByText(/all repos/)).toBeNull(); +}); +``` + +`ExplainModal.test.tsx`, append (extend the fetch stub's router to answer `?repo=` explains from `explainGet` as it already does, since the URL still starts with `/api/settings/explain/`): + +```tsx +it('with all repos, lists each repo that sets the key and switches to it', async () => { + const REPO = 'gitlab.example.com/acme/app'; + const ROLES: SettingDefWire = { + ...DEF, + key: 'rt.roles', + type: 'object', + scopes: ['user', 'team', 'machine'], + merge: 'deep', + repoScoped: true, + repos: [{ identity: REPO, scopes: ['team'] }], + effective: { scope: null, file: null }, + }; + explainGet.mockImplementation(async (url: string) => + ok({ + def: ROLES, + rows: url.includes('repo=') + ? [ + { scope: 'default', file: null, present: false }, + { + scope: 'team.repo', + file: '/home/team/settings.team.jsonc', + present: true, + value: { dev: { fixedPort: 3000 } }, + }, + ] + : [{ scope: 'default', file: null, present: false }], + }) + ); + const onPickRepo = vi.fn(); + const queryClient = new QueryClient(); + renderWithProviders( + + + + ); + const section = await screen.findByTestId(`repo-${REPO}`); + expect(within(section).getByText('acme/app')).toBeInTheDocument(); + expect(within(section).getByText('team · repo')).toBeInTheDocument(); + expect(section).toHaveTextContent('"fixedPort": 3000'); + await userEvent.click(within(section).getByRole('button', { name: 'Show acme/app' })); + expect(onPickRepo).toHaveBeenCalledWith(REPO); +}); +``` + +Run: `bun run console:test` +Expected: FAIL on the new tests. + +- [ ] **Step 2: `repoLabel` and a tolerant `useRepos`** + +`view.ts`: + +```ts +/** A repo identity's display label: everything after the host, the same + rule settings-kit's /repos uses. */ +export function repoLabel(identity: string): string { + const at = identity.indexOf('/'); + return at < 0 ? identity : identity.slice(at + 1); +} +``` + +In `useRepos`, `.then(body => alive && setRepos(body.repos ?? []))`. + +- [ ] **Step 3: `RepoPicker.tsx`** + +```tsx +import { Select } from '@mattstack/app-kit/core'; + +import { INPUT_TYPE } from './controlStyles'; +import { useRepos } from './useConsoleSettings'; +import { repoLabel } from './view'; + +const ALL = ''; + +/** All repos, or one repo whose sections resolve the repo-scoped rows. A + picked repo the list does not name yet (still loading, or only in the + url) stays selectable under its own label. */ +export function RepoPicker({ + value, + onChange, +}: { + value: string | null; + onChange: (repo: string | null) => void; +}) { + const { repos } = useRepos(); + const known = repos.some(r => r.identity === value); + return ( + { + if (v !== null) onChange(v); + }} + /> + ); + if (spec.type === 'number') + return ( + { + setRaw(v); + if (typeof v === 'number') onChange(v); + else if (v === '') onChange(undefined); + }} + /> + ); + const text = typeof value === 'string' ? value : ''; + const change = (v: string) => onChange(v === '' ? undefined : v); + return spec.suggestions ? ( + + ) : ( + + ); +} + +function Line({ + name, + hint, + children, + error, +}: { + name: ReactNode; + hint?: string; + children: ReactNode; + error?: string; +}) { + const { text } = useSchemeColors(); + return ( + + + + {name} + {hint && ( + + {hint} + + )} + + + {children} + + + {error && ( + + {error} + + )} + + ); +} + +/** One object's fields: required first, then set or added optional ones, + an Add property menu, and read-only rows for properties the form does + not draw (kept as they are on save). */ +export function FieldGrid({ + shape, + entry, + onChange, + disabled, + issues, +}: { + shape: FormShape; + entry: Entry; + onChange: (next: Entry) => void; + disabled: boolean; + issues: SchemaIssue[]; +}) { + const { text } = useSchemeColors(); + const [shown, setShown] = useState([]); + const set = (name: string, v: unknown) => { + const next = { ...entry }; + if (v === undefined) delete next[name]; + else next[name] = v; + onChange(next); + }; + const drop = (name: string) => { + setShown(s => s.filter(n => n !== name)); + set(name, undefined); + }; + const errorFor = (name: string) => + issues.find(i => i.path[0] === name)?.message; + const addable = addableFields(shape, entry, shown); + const extras = extraKeys(shape, entry); + + return ( + + {visibleFields(shape, entry, shown).map(name => { + const spec = shape.fields[name]!; + const required = shape.required.includes(name); + return ( + + {spec.title ?? name} + + } + hint={spec.description} + error={errorFor(name)} + > + set(name, v)} + /> + {!required && ( + drop(name)} + > + + + )} + + ); + })} + {extras.map(name => ( + + {name} + + } + > + + {JSON.stringify(entry[name])} + + + ))} + {extras.length > 0 && ( + + Read-only here and kept as they are; use Edit as JSON to change them. + + )} + {addable.length > 0 && ( + + + + + + + {addable.map(name => ( + setShown(s => [...s, name])} + > + {shape.fields[name]!.title ?? name} + + ))} + + + + )} + + ); +} +``` + +(Every field input's accessible name is the property name, so tests and screen readers find `pattern`, `url`, ... inside a card. The visible label may be the schema `title`.) + +- [ ] **Step 5: `ItemCards.tsx`** + +```tsx +import { useRef, useState } from 'react'; +import { + ActionIcon, + Box, + Button, + Group, + Stack, + Text, +} from '@mattstack/app-kit/core'; +import { useSchemeColors } from '@mattstack/app-kit/hooks'; +import { Icons } from '@mattstack/app-kit/icons'; +import type { SchemaIssue } from '@mattstack/settings-kit/shapes'; + +import { FieldGrid } from './FieldGrid'; +import { newEntry, type FormShape } from './formShape'; +import { issuesUnder } from './issues'; + +type Entry = Record; + +export const CARD_STYLE = { + border: '1px solid var(--tk-border-soft)', + borderRadius: 4, + background: 'var(--tk-card)', +} as const; + +/** One card per item, in order. Cards carry stable ids so a card's local + state (the optional fields it revealed) follows it through a reorder. */ +export function ItemCards({ + shape, + value, + onChange, + disabled, + issues, +}: { + shape: FormShape; + value: Entry[]; + onChange: (next: Entry[]) => void; + disabled: boolean; + issues: SchemaIssue[]; +}) { + const { text } = useSchemeColors(); + const next = useRef(value.length); + const [ids, setIds] = useState(() => value.map((_, i) => i)); + const swap = (list: T[], a: number, b: number) => { + const out = [...list]; + [out[a], out[b]] = [out[b]!, out[a]!]; + return out; + }; + const move = (from: number, to: number) => { + setIds(swap(ids, from, to)); + onChange(swap(value, from, to)); + }; + const remove = (at: number) => { + setIds(ids.filter((_, i) => i !== at)); + onChange(value.filter((_, i) => i !== at)); + }; + const add = () => { + setIds([...ids, next.current++]); + onChange([...value, newEntry(shape)]); + }; + const first = shape.required[0]; + + return ( + + {value.map((item, i) => ( + + + + {`#${i + 1}`} + {first && typeof item[first] === 'string' && ( + + {` ${item[first] as string}`} + + )} + + + move(i, i - 1)} + > + + + move(i, i + 1)} + > + + + remove(i)} + > + + + + + onChange(value.map((x, j) => (j === i ? e : x)))} + /> + + ))} + + + + + ); +} +``` + +Check the icon names used (`chevronUp`, `chevronDown`, `trash`, `plus`, `close`) exist in the kit registry (`packages/ui/src/icons/Icons.ts`); all five are already used in `apps/console/src/app/settings/`. + +- [ ] **Step 6: `DraftEditor.tsx` (form mode)** + +```tsx +import { useState, type KeyboardEvent } from 'react'; +import { Button, Group, Stack, Text } from '@mattstack/app-kit/core'; +import { useSchemeColors } from '@mattstack/app-kit/hooks'; +import type { SettingDefWire } from '@mattstack/settings-kit/react'; +import { checkValue } from '@mattstack/settings-kit/shapes'; + +import type { FormShape } from './formShape'; +import { issueText } from './issues'; +import { ItemCards } from './ItemCards'; + +type Entry = Record; + +function emptyOf(form: FormShape): unknown { + return form.kind === 'objectList' ? [] : {}; +} + +/** A local draft of one layer's value, checked against the def's layer + schema as it changes and saved only when it passes. Escape and Cancel + discard it; Escape is marked handled so an enclosing modal stays open. */ +export function DraftEditor({ + def, + form, + initial, + targetLabel, + saving, + onSave, + onCancel, +}: { + def: SettingDefWire; + form: FormShape; + initial: unknown; + targetLabel: string; + saving: boolean; + onSave: (value: unknown) => Promise; + onCancel: () => void; +}) { + const { text } = useSchemeColors(); + const start = initial ?? emptyOf(form); + const [draft, setDraft] = useState(() => structuredClone(start)); + const schema = def.layerSchema ?? def.schema; + const issues = schema ? checkValue(schema, draft) : []; + const changed = JSON.stringify(draft) !== JSON.stringify(start); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape' || e.defaultPrevented) return; + if ((e.target as HTMLElement).closest('[role="menu"], [role="listbox"]')) + return; + e.preventDefault(); + onCancel(); + }; + + return ( + + {form.kind === 'objectList' && ( + + )} + {issues[0] && ( + + {issueText(issues[0])} + + )} + + + {`saves to ${targetLabel}`} + + + + + + ); +} +``` + +Callers remount `DraftEditor` (a changing `key`) to discard the draft: `onCancel` bumps that key. + +- [ ] **Step 7: Wire forms into composite rows** + +`view.ts`: add + +```ts +export function targetLabel(t: WriteTarget): string { + return t.repo ? `${t.scope} · ${repoLabel(t.repo)}` : t.scope; +} +``` + +(`repoLabel` exists from Task 6), add `'objectList'` to `EDITOR_KINDS`, and make `isEditable` read `EDITOR_KINDS.has(editorKind(def))` (import `editorKind` from `./formShape`; `formShape.ts` imports nothing from `view.ts`, so there is no cycle). + +`CompositeControls.tsx`: add a `FormBody`: + +```tsx +/** A form over the target layer's own value. A deep key's draft starts from + that layer's authored value, never the merged view, so defaults and + other layers are never copied into it; a replace key starts from the + value in effect, as the list editors do. */ +function FormBody({ + def, + row, + form, +}: { + def: SettingDefWire; + row: Row; + form: FormShape; +}) { + const { text } = useSchemeColors(); + const repo = useSettingsRepo(); + const explained = useKeyExplain(def.key, repo); + const [resets, setResets] = useState(0); + const at = rungOf(row.target.scope, row.target.repo ?? null); + const deep = def.merge === 'deep'; + if (deep && explained.rows.length === 0) + return ( + + + + ); + const initial = deep + ? explained.rows.find(r => r.scope === at && r.present)?.value + : def.effective.value; + if (initial !== undefined && !canDraw(form, initial)) + return ( + + + + This value does not fit the form. + + + ); + return ( + + setResets(n => n + 1)} + onSave={async value => { + const empty = + deep && + typeof value === 'object' && + value !== null && + Object.keys(value).length === 0; + const ok = await (empty ? row.clear(at) : row.save(value)); + if (ok) explained.refresh(); + return ok; + }} + /> + + ); +} +``` + +In `compositeParts`, compute `const form = formOf(def);` and `const edit = editorKind(def);`, and before the `stringList`/`stringMap`/`leaves` guard add: + +```tsx + if ((edit === 'objectList' || edit === 'objectMap') && form) { + if (def.effective.invalid !== undefined) + return { control: , body: null }; + return { + control: ( + + ), + body: open ? : null, + }; + } +``` + +(An unset form row still expands, so the first item can be added.) Task 9 renders `objectMap` in `DraftEditor`; until then an `objectMap` form row expands to an editor with only Save and Cancel, which Task 9 fills in the same branch. Imports: `Skeleton` from `@mattstack/app-kit/core`, `canDraw`, `editorKind`, `formOf`, `type FormShape` from `./formShape`, `DraftEditor`, `JsonBlock`, `useKeyExplain`, `useSettingsRepo`, `rungOf`, `targetLabel`. + +Run: `cd apps/console && bunx vitest run src/app/settings/ItemCards.test.tsx && cd ../..` +Expected: PASS. If the Escape test fails because focus leaves the input when the draft remounts, assert on a fresh query of the input (as written) rather than a held reference. + +- [ ] **Step 8: Gates, UI validation, commit** + +Run each, bare: `bun run console:typecheck`, `bun run console:lint`, `bun run console:test`, `sh scripts/repo-purity.sh`, `bun run format:check` + +UI validation (recipe, both schemes): expand `rt.notify.eventBridges` on the page. Cards read as distinct surfaces from the row body in both schemes (if a card on `--tk-card` blends into the modal or page surface in dark, try `--tk-raised`; say which you chose and why); field labels align with today's leaves rows; Add item appends a card with Save disabled and the issue line naming `[n].pattern`; Add property's menu opens and reveals a field; Cancel restores. Writes are stubbed by the recipe, so a Save shows the stub's refusal under the row: confirm it renders as today's refused-save line. + +```bash +git add apps/console/src/app/settings +git commit -m "console: item cards for lists of objects, over a checked local draft + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- +### Task 9: Named sections (`objectMap`), including `deck.apps` + +Maps of objects get one section per entry. This covers settings-kit's `objectMap` keys (`gitq.forges`) and the keys `formShape` widens (`deck.apps`, `rt.roles`, `rt.sdmEnrichment`), whose nested properties (`deck.apps`'s `override`) are drawn read-only and kept on save. + +**Needs from spec 1's wire contract:** as Task 8, plus `layerSchema` for deep map keys (`gitq.forges`, `deck.apps` merge deep, so a layer's entries may omit properties the full schema requires) and the `labels: { key, value }` metadata on a record schema. + +**Files:** +- Create: `apps/console/src/app/settings/NamedSections.tsx`, `NamedSections.test.tsx` +- Modify: `apps/console/src/app/settings/DraftEditor.tsx`, `CompositeControls.tsx`, `view.ts` + +**Interfaces:** +- Consumes: `FieldGrid`, `CARD_STYLE`, `newEntry`, `issuesUnder` (Task 8). +- Produces: `NamedSections({ shape, value, onChange, disabled, issues })`; `rowSummary(def: SettingDefWire): string` in `CompositeControls.tsx` (entries count for a form map, else settings-kit's `summarize`); `EDITOR_KINDS` gains `objectMap`. + +- [ ] **Step 1: Write the failing tests** + +Create `apps/console/src/app/settings/NamedSections.test.tsx`: + +```tsx +import { renderWithProviders } from '@mattstack/app-kit/test-utils'; +import type { + ExplainRowWire, + SettingDefWire, +} from '@mattstack/settings-kit/react'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { SettingRow } from './SettingRow'; +import { schemaFields } from './testSchemas'; + +const store = () => ({ + set: vi.fn(async () => null as string | null), + unset: vi.fn(async () => null as string | null), + move: vi.fn(async () => null as string | null), +}); + +function deepDef( + key: string, + value: Record, + scopes: string[] = ['user'] +): SettingDefWire { + return { + key, + type: 'object', + scopes: scopes as SettingDefWire['scopes'], + merge: 'deep', + secret: false, + teamLocked: false, + repoScoped: false, + writable: true, + description: 'A map.', + hasDefault: true, + defaultValue: {}, + effective: { scope: 'user', file: '/home/user/settings.user.jsonc', value }, + storeVersion: 1, + ...schemaFields(key), + }; +} + +function stubRows(rows: ExplainRowWire[]) { + vi.stubGlobal('fetch', async () => ({ + ok: true, + status: 200, + json: async () => ({ def: null, rows }), + })); +} +afterEach(() => vi.unstubAllGlobals()); + +async function openRow(def: SettingDefWire, s = store()) { + renderWithProviders(); + await userEvent.click(screen.getByRole('button', { name: /^\d+ entr(y|ies)$/ })); + return s; +} + +describe('named sections', () => { + const DEFAULT_FORGE = { 'gitlab.example.com': { provider: 'gitlab' } }; + const USER_FORGE = { 'github.example.com': { provider: 'github', tokenEnv: 'GH_TOKEN' } }; + + it("a deep map edits only the target layer's own entries", async () => { + stubRows([ + { scope: 'default', file: null, present: true, value: DEFAULT_FORGE }, + { scope: 'user', file: '/home/user/settings.user.jsonc', present: true, value: USER_FORGE }, + ]); + const s = await openRow(deepDef('gitq.forges', { ...DEFAULT_FORGE, ...USER_FORGE })); + expect(await screen.findByTestId('entry-github.example.com')).toBeInTheDocument(); + expect(screen.queryByTestId('entry-gitlab.example.com')).toBeNull(); + await userEvent.type(screen.getByLabelText('new host'), 'git.example.org'); + await userEvent.click(screen.getByRole('button', { name: 'Add entry' })); + const added = screen.getByTestId('entry-git.example.org'); + await userEvent.click(within(added).getByRole('button', { name: 'Add property' })); + await userEvent.click(await screen.findByRole('menuitem', { name: 'baseUrl' })); + await userEvent.type(within(added).getByLabelText('baseUrl'), 'https://git.example.org'); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(s.set).toHaveBeenCalledWith('gitq.forges', 'user', { + ...USER_FORGE, + 'git.example.org': { baseUrl: 'https://git.example.org' }, + }) + ); + }); + + it('rejects an empty or duplicate entry name', async () => { + stubRows([ + { scope: 'user', file: '/home/user/settings.user.jsonc', present: true, value: USER_FORGE }, + ]); + await openRow(deepDef('gitq.forges', USER_FORGE)); + await screen.findByTestId('entry-github.example.com'); + await userEvent.click(screen.getByRole('button', { name: 'Add entry' })); + expect(screen.getByText('host is required')).toBeInTheDocument(); + await userEvent.type(screen.getByLabelText('new host'), 'github.example.com'); + await userEvent.click(screen.getByRole('button', { name: 'Add entry' })); + expect(screen.getByText('github.example.com already exists')).toBeInTheDocument(); + }); + + it('removes an entry', async () => { + stubRows([ + { scope: 'user', file: '/home/user/settings.user.jsonc', present: true, value: USER_FORGE }, + ]); + const s = await openRow(deepDef('gitq.forges', USER_FORGE)); + await userEvent.click( + await screen.findByRole('button', { name: 'remove entry github.example.com' }) + ); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + // An emptied deep layer is removed rather than written as {}. + await waitFor(() => expect(s.unset).toHaveBeenCalledWith('gitq.forges', 'user')); + }); + + it('deck.apps draws its nested override read-only and keeps it on save', async () => { + const APPS = { + 'acme-app': { published: true, override: { devPort: 5173, basePort: 4100 } }, + }; + stubRows([ + { scope: 'user', file: '/home/user/settings.user.jsonc', present: true, value: APPS }, + ]); + const s = await openRow(deepDef('deck.apps', APPS)); + const section = await screen.findByTestId('entry-acme-app'); + expect(within(section).getByText('override')).toBeInTheDocument(); + expect(within(section).getByText('{"devPort":5173,"basePort":4100}')).toBeInTheDocument(); + expect( + within(section).getByText(/use Edit as JSON to change them/) + ).toBeInTheDocument(); + await userEvent.click(within(section).getByRole('switch', { name: 'published' })); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(s.set).toHaveBeenCalledWith('deck.apps', 'user', { + 'acme-app': { published: false, override: { devPort: 5173, basePort: 4100 } }, + }) + ); + }); +}); +``` + +(Mantine's `Switch` input carries `role="switch"`; if it renders as a checkbox in 9.5, use `getByRole('checkbox', { name: 'published' })`.) + +Run: `cd apps/console && bunx vitest run src/app/settings/NamedSections.test.tsx && cd ../..` +Expected: FAIL. + +- [ ] **Step 2: `NamedSections.tsx`** + +```tsx +import { useState } from 'react'; +import { + ActionIcon, + Box, + Button, + Group, + Stack, + Text, + TextInput, +} from '@mattstack/app-kit/core'; +import { useSchemeColors } from '@mattstack/app-kit/hooks'; +import { Icons } from '@mattstack/app-kit/icons'; +import type { SchemaIssue } from '@mattstack/settings-kit/shapes'; + +import { INPUT_TYPE } from './controlStyles'; +import { FieldGrid } from './FieldGrid'; +import { newEntry, type FormShape } from './formShape'; +import { CARD_STYLE } from './ItemCards'; +import { issuesUnder } from './issues'; + +type Entry = Record; + +/** One section per entry, titled by its name. A new entry needs a name + that is neither empty nor already taken. */ +export function NamedSections({ + shape, + value, + onChange, + disabled, + issues, +}: { + shape: FormShape; + value: Record; + onChange: (next: Record) => void; + disabled: boolean; + issues: SchemaIssue[]; +}) { + const { text } = useSchemeColors(); + const [name, setName] = useState(''); + const [nameError, setNameError] = useState(null); + const [noun] = shape.labels; + const add = () => { + const n = name.trim(); + if (!n) return setNameError(`${noun} is required`); + if (Object.hasOwn(value, n)) return setNameError(`${n} already exists`); + onChange({ ...value, [n]: newEntry(shape) }); + setName(''); + setNameError(null); + }; + const remove = (key: string) => { + const next = { ...value }; + delete next[key]; + onChange(next); + }; + + return ( + + {Object.entries(value).map(([key, entry]) => ( + + + + {key} + + remove(key)} + > + + + + onChange({ ...value, [key]: e })} + /> + + ))} + + + { + setName(v); + setNameError(null); + }} + onKeyDown={e => { + if (e.key === 'Enter') add(); + }} + /> + + + {nameError && ( + + {nameError} + + )} + + + ); +} +``` + +- [ ] **Step 3: Render it, count entries, mark `objectMap` editable** + +`DraftEditor.tsx`: beside the `objectList` branch, + +```tsx + {form.kind === 'objectMap' && ( + } + onChange={setDraft} + disabled={saving} + issues={issues} + /> + )} +``` + +`CompositeControls.tsx`: + +```ts +/** A form map counts its entries whatever settings-kit calls the key (a + widened `json` key would otherwise read "N fields"). */ +export function rowSummary(def: SettingDefWire): string { + const v = def.effective.value; + if (editorKind(def) !== 'objectMap') return summarize(def); + const n = + typeof v === 'object' && v !== null && !Array.isArray(v) + ? Object.keys(v).length + : 0; + return `${n} ${n === 1 ? 'entry' : 'entries'}`; +} +``` + +and use `rowSummary(def)` instead of `summarize(def)` in the form branch's `ExpandToggle` label. `view.ts`: add `'objectMap'` to `EDITOR_KINDS`. + +Run: `cd apps/console && bunx vitest run src/app/settings/NamedSections.test.tsx && cd ../..` +Expected: PASS. + +- [ ] **Step 4: Gates, UI validation, commit** + +Run each, bare: `bun run console:typecheck`, `bun run console:lint`, `bun run console:test`, `sh scripts/repo-purity.sh`, `bun run format:check` + +UI validation (recipe, both schemes): expand `deck.apps` (one section per app, `override` read-only with the note, `published` switch), `gitq.forges` if set, and `rt.roles` with the team repo picked (sections for each role with `fixedPort`/`hook` editable and `pool`/`needs`/`env` read-only). Check the section title row, the read-only JSON cells (no overflow past the 260px column, wrapping legibly) and the Add entry row. Try an empty and a duplicate name. + +```bash +git add apps/console/src/app/settings +git commit -m "console: named sections for maps of objects, nested properties kept read-only + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- +### Task 10: JSON editor, Edit as JSON on every JSON row, layer editors in the explain modal + +**Needs from spec 1's wire contract:** `schema` / `layerSchema` (the JSON editor completes and lints against `layerSchema ?? schema`); `checkValue`; explain rows' per-layer `value` (the modal's layer editor starts from it). + +**Files:** +- Create: `apps/console/src/app/settings/JsonDraft.tsx`, `JsonEditor.test.tsx` +- Modify: `apps/console/src/app/settings/DraftEditor.tsx`, `CompositeControls.tsx`, `SettingRow.tsx`, `RowMenu.tsx`, `ExplainModal.tsx`, `view.ts` + +**Interfaces:** +- Consumes: kit `CodeMirror` with `jsonSchema` and `jsonCheck` (Task 4); `formOf`, `canDraw`, `editorKind` (Task 8). +- Produces: + - `JsonDraft({ text, onText, schema }: { text: string; onText: (t: string) => void; schema: JsonSchema | undefined })`. + - `DraftEditor` props become `{ def; form: FormShape | null; initial: unknown; startIn?: 'form' | 'json'; targetLabel; saving; onSave; onCancel }`. It opens in the form when `form` can draw the start value and `startIn !== 'json'`, else in JSON. The toggle button reads `Edit as JSON` / `Edit as form`. + - `DraftBody({ def, row, form, startIn })` in `CompositeControls.tsx` (Task 8's `FormBody`, generalised: `form` may be null). + - `compositeParts(def, kind, row, open, onToggle, asJson, onDoneJson, onEditJson)`: `asJson` renders the JSON editor for any JSON row kind; `onEditJson` feeds the list, map and leaves bodies' Edit as JSON button. + - `RowMenu` gains `onEditJson?: () => void` (an `Edit as JSON` item); the menu renders whenever it has any item. + - `LayerLine` edits composite layers with a `DraftEditor` under the line. + - `EDITOR_KINDS` gains `json`. + +- [ ] **Step 1: Write the failing tests** + +Create `apps/console/src/app/settings/JsonEditor.test.tsx`: + +```tsx +import { renderWithProviders } from '@mattstack/app-kit/test-utils'; +import type { + ExplainRowWire, + SettingDefWire, +} from '@mattstack/settings-kit/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@mattstack/app-kit/lazy', () => ({ + CodeMirror: ({ + value, + onChange, + }: { + value?: string; + onChange?: (v: string) => void; + }) => ( +