Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 26 additions & 18 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,8 +561,8 @@ _Full spec-driven breakdown in [`docs/specs/PHASE-30-SPEC.md`](docs/specs/PHASE-

Run on its own viewer (2026-11-08), framesmith scored every page's usability 100 with zero findings: the viewer has a 17px Back link and 21 click targets that are plain spans, but the web hit-target rule only measures form controls. Meanwhile page rules misfired on the app: the sidebar project list read as a data table, the dense 13/14px type steps failed the scale ratio, and the projects' identity dots counted as competing accents. framesmith judges an application as if it were a marketing page, and has no words for what makes it an application. This phase adds a third type, `desktop-app` (a tool you work in, in a browser or a native window), with the vocabulary, rules, layouts, overlays and element states an app needs. Acceptance is literal: the viewer itself rebuilt as canvases, CLEAN, with every click target a primitive.

- [ ] Slice A — the type: `desktop-app` with a 1440×900 window profile, a ruleset that can switch off the type-scale ratio, the WCAG 2.5.8 spacing exception on the 24px floor, navigation lists that never read as tables and identity colors that never count as accents (both on every type), evaluate and import results an agent can read, type-aware generation with state tokens, and the baseline canvases committed as fixtures
- [ ] Slice B — vocabulary: a 24px `button` with an accessible name, `link`, `text-input`, `nav-item`, `tabs`, `tree-row`, `menu-row`, `property-row` and `divider`
- [x] Slice A — the type: `desktop-app` with a 1440×900 window profile, a ruleset that switches off the type-scale ratio, the WCAG 2.5.8 spacing exception on the 24px floor (web and desktop-app; mobile keeps its 44px rule) and navigation lists that never read as tables (every type), evaluate and import results an agent can read (repeated findings fold, keeping every fix), type-aware generation with state and control-color tokens, and the baseline canvases committed as fixtures
- [ ] Slice B — vocabulary: a 24px `button` with an accessible name, `link`, `text-input`, `nav-item`, `tabs`, `tree-row`, `menu-row`, `property-row` and `divider`, plus identity dots that never count as accents (moved here from slice A: it keys on `nav-item`)
- [ ] Slice C — app rules: every click target measured, hand-built targets named, icon-only actions named, one current item per navigation group, a dense type ladder, row rhythm, honest truncation, one primary per region, and the declaration matching the window
- [ ] Slice D — panes: resolution for any number of types and six desktop-app realisations (workbench, list-detail, editor-inspector, browser, dashboard, settings) under the pattern gate
- [ ] Slice E — overlays: one overlay layer at the root with a fixed ladder, and the `tooltip`, `popover`, `menu`, `modal`, `toast` and `drawer` primitives, with overlay screen states as variants
Expand Down
51 changes: 37 additions & 14 deletions docs/GUIDELINES.md

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion docs/specs/PHASE-31-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ One profile ships, keyed by id so a second size is one table row later. Values i
| **FR-A4** | **Targets on web and desktop-app, with the spacing exception.** The 24px floor (`checkUsability`, `evaluate.ts:1979`) keeps its existing first step for the four controls: a lone control grows to its padded row (`evaluate.ts:2016-2043`), and a control that is the only one inside a `property-row`, `nav-item`, `menu-row` or `list-row` owns that primitive's box. It then measures every clickable primitive on `web` and `desktop-app`, not only the four controls, and applies the target circle to whatever is still below 24. **Detailed mode is authoritative**: it runs the circle test on rendered rects and reports an error when it fails. **Fast mode** has no rects, so a target still below 24 after the row step is a `warning` naming the spacing exception and detailed mode; it blocks presenting until detailed mode clears or confirms it. For the four controls this downgrades today's error in both modes (`evaluate.ts:2054-2063`) to a fast-mode warning and a detailed-mode error unless the circle clears it. Instances are expanded for the census, as on mobile (`expandForRules`, `evaluate.ts:2235`). Inline links (FR-B2) are exempt, as WCAG exempts inline targets. Mobile's 44px floor is unchanged. | Two 20px icon buttons with centres 24px apart pass in detailed mode; the same buttons touching fail with a message naming the 24px circle; both warn in fast mode. A lone 18px checkbox in a padded row still passes. The slice A PR lists every existing fixture whose usability verdict moved, and why. |
| **FR-A5** | **Navigation lists are not tables, on every type.** `detectTable` (`src/drift.ts:106`) already rejects primitive rows, since it requires frame rows. The misfire is the hand-built list, so it gains a positive discriminator: a frame is not a table when it has three or more rows, every row's first child is an icon, a dot (an `ellipse`, or a frame or rectangle of 12px or less on both axes with no children), or an image of 24px or less, and its first row is not a header row (a row whose cells are all text). Stress's own row reader (`tableRows`, `src/stress.ts:129`) applies the same rule. The sidebar's texts then enter the inventory as texts, which changes what stress perturbs on such a canvas. | The baseline sidebar fixture (FR-A9) yields no table and no coverage finding. A Region / Revenue table with a header row, the checkout totals rows, and a users table whose rows start with a 24px avatar under a text header row all still detect, as does every existing table fixture. |
| **FR-A6** | **Identity dots are not accents, on every type.** `nav-item` gains `dotColor` for an identity dot (FR-B3); `color` keeps its existing meaning, the text color (`src/types.ts:183`). A color value that appears in the tree only as `nav-item` `dotColor` is excluded from the accent-consistency census (`evaluate.ts:1757-1806`); the same value used as a literal anywhere else still counts. Hand-built dots keep counting, because nothing marks them as identity. | The baseline gallery rebuilt with `nav-item dotColor` dots does not trip `accent-consistency`; a canvas with two literal accent fills on buttons still does; a hand-built dot with an identity color still counts. |
| **FR-A7** | **Results an agent can read.** The serialised `canvas_evaluate` result folds `info` findings that share a key of category, `rule` or `tell` (empty when neither is set), and message template (the message with quoted text and numbers stripped) into one entry carrying `nodeIds[]` and `count`, once three or more share a key. Errors, warnings, and infos that carry a `fix` are never folded. The fold runs on the serialised result only: `canvas_autofix` and the directive read the unfolded list. `canvas_import_url` and `canvas_import_html` return `snapped`, `scaleMatches` and `literals` (`src/import.ts:157-162`) as per-token counts with the first five node ids each, and the full lists under a new `verbose: true` input. | On the baseline detail fixture (FR-A9), the default import report and the fast `canvas_evaluate` result are each at most half their 2.4.0 size (about 60,000 and 77,000 characters), measured in the slice A PR. `canvas_autofix` returns the same ops before and after. |
| **FR-A7** | **Results an agent can read.** The serialised `canvas_evaluate` result folds repeated findings into one entry carrying `nodeIds[]`, `count` and `fixes[]` (as changed by spec change S1, see "Slice A — as built"): warnings and infos fold once three or more share a key of severity, category, `rule` or `tell`, theme and message (fix-bearing members on the message's shape, members without a fix only when message and suggestion read the same); errors never fold. The fold runs on the serialised result only: `canvas_autofix` and the directive read the unfolded list. `canvas_import_url` and `canvas_import_html` return `snapped`, `scaleMatches` and `literals` (`src/import.ts:157-162`) as per-token counts with the first five node ids each, and the full lists under a new `verbose: true` input. | On the baseline detail fixture (FR-A9), the default import report is at most half its 2.4.0 size (about 60,000 characters), and the fast `canvas_evaluate` result is at most 58% of its unfolded size (amended under spec change S1: folding without losing any member's message reaches about 55%), measured in the slice A PR. `canvas_autofix` returns the same ops before and after. |
| **FR-A8** | **Type-aware generation and state tokens.** `typeBase` (`src/design-language.ts:169-177`) gains a desktop-app branch: base 14 for every personality except `data-dense` (13); an explicit `baseSize` wins; its `source` union gains `'desktop-app'`. `DesignVariables` gains a numeric `state` section (`hover`, `focus`, `pressed`, `disabledContent`, `disabledContainer`, as opacities from the constants table), alongside the existing `motion` and `elevation` sections (`src/types.ts:334-343`), with the same workspace, project and canvas layering. The color engine (`src/color-system.ts`) emits `focus-ring`, picked to clear 3:1 against every generated `bg-*` and `*-tint` token in both themes (a filled primitive's own fill is covered by FR-G1's inner ring, since no single color can clear 3:1 against both the dark surfaces and a light primary fill), and `border-strong`, picked to clear 3:1 against `bg-primary` and `bg-surface` in both themes. | Each personality generated on a desktop-app canvas yields `body` 13 or 14 and nothing below 11; `focus-ring` measures 3:1 or more against every `bg-*` and `*-tint` token, and `border-strong` against `bg-primary` and `bg-surface`, in both themes. |
| **FR-A9** | **The baseline is reproducible.** The four imported viewer canvases (gallery, web detail, mobile detail, archive) are committed under `test-fixtures/viewer-baseline/` in the slice A PR, with their sha256 recorded in the PR. Every acceptance in this spec that names a baseline fixture runs against those files. | The files exist, and a test loads each one. |
| **FR-B1** | **A 24px button with a name.** `button` gains `size: 'xs'` (24 tall, 8px horizontal padding, 12px label; icon-only `xs` is 24×24) and `ariaLabel?`, which renders `aria-label` and `title` and never renders text. A button is icon-only when `label` is blank and `icon` is set (`buttonBox`, `src/primitives.ts:62`). `size` widens to `'xs' \| 'sm' \| 'md' \| 'lg'` (`src/types.ts:303`). Heights stay type-independent. `xs` fails the mobile 44px floor by design, which the docstring states. | `snapshot_layout` reads an icon-only `xs` button at 24×24; with `ariaLabel` it renders `aria-label` and `title` and no text. |
Expand Down Expand Up @@ -183,6 +183,22 @@ One profile ships, keyed by id so a second size is one table row later. Values i
- `test-fixtures/viewer-baseline/`: the four baseline canvases.
- `src/viewer.ts:1171` (`renderDetailPage`): window-width buttons.

**Slice A — as built (recorded 2026-11-09; read this before slice B).**
- **Spec change S1 (FR-A7, approved by Victor).** Info-only folding could not reach the size acceptance: on the baseline detail page most of the volume is repeated spacing *warnings* that carry fixes. The serialised `canvas_evaluate` result now folds repeated **warnings and infos** once three or more share a key of severity, category, rule or tell, theme and message: members that carry a fix match on the message's shape (quoted text and numbers stripped, since the fix op holds each node's specifics); members without one match only when the message reads the same apart from quoted node text and the suggestion is identical (verify round 1: a looser key folded a 1.07 ratio and a 2.67 one under one message). The folded entry keeps the first member's message and suggestion and carries distinct `nodeIds`, `count`, and `fixes: [{ nodeId, op }]` for every member that had a fix. Errors never fold. `canvas_autofix` and the directive read the unfolded list. The shipped function is `foldIssues` (`src/evaluate.ts`). Measured on the baseline detail fixture with the final key: 74,609 → 41,008 characters pretty-printed (55%; the looser first key reached 52% by losing members' messages); the acceptance is amended to ≤ 58% and the test asserts it. Other fixtures: Ridgeline 55%, gallery 56%, archive 74%. The import report summary measured 61,102 → 11,916 on a live re-import of the same page.
- **D1: FR-A6 moves to slice B.** The identity-dot exclusion keys on `nav-item` `dotColor`, which does not exist until FR-B3. It lands with `nav-item`.
- **D2: `desktop-app-artboard` is partial.** Slice A ships the window-width clause (a desktop-app canvas narrower than 1024 warns); the clauses about desktop-app and overlay primitives on other types land with those primitives (slices B and E). The rule lives in `src/desktop-app-rules.ts`.
- **D3: the slice A census** is the four controls plus the existing tappable primitives (`button`, `list-row`, `tab-bar` tabs and FAB, `nav-bar` actions). New primitives join it in slice B. Web's row step still counts controls only, so a checkbox beside a button keeps its lone-control row (the existing verdict).
- **D4: stress needed no change for FR-A5.** `stress.ts` reads rows only from tables the shared inventory already detected, so the `detectTable` discriminator covers it.
- **D5: the dot test tolerates a missing dimension.** Imported dots carry one size (the baseline's sidebar dot has `width: 6` and no height), so a dot is a childless frame or rectangle with at least one known dimension, every known one 12px or less.
- **D6: two baseline tables remain, both importer shapes.** The gallery's card grid (imported as rows of frames whose score badges read as a header) and the inspector's category list still detect on the fixtures. Neither is a navigation list; the importer is out of scope (Non-goals).
- **D7: the fixtures carry their tokens.** Each `test-fixtures/viewer-baseline/*.json` embeds the repo workspace design system in `variables`, so the canvases resolve in the isolated test store exactly as they did in the baseline run.
- **D8: detailed rects come from a second render only when needed.** The circle test renders the instance-expanded tree once, and only when a target is undersized, so web's detailed consistency check keeps its existing render. `computeLayout` returns a tree; the census indexes it. A target the layout walker cannot place (inside an element with no node id) keeps the blocking warning, which then says detailed mode could not place it.
- **D9: a table whose header row also leads with a mark reads as a list.** The discriminator asks whether every row leads with a mark; a header with a blank spacer or a select-all checkbox frame in its first cell matches, so such a table loses detection. The spec's own definition ("its first row is not a header row, a row whose cells are all text") leaves that case where the code puts it. Accepted for slice A; revisit if a real table hits it.
- **D11: an import-named `Table` keeps detection.** The FR-A5 discriminator skips frames named `Table`, the trusted shape the importer emits (Phase 18), so an imported table whose rows lead with a dot or avatar keeps coverage and stress handling.
- **D12: a primitive's sub-targets are never placed individually.** A `tab-bar`'s tabs and a `nav-bar`'s actions render under the primitive's one node id, so in detailed mode an undersized tab keeps the blocking warning instead of a circle test against the whole bar (verify round 1 found the bar's centre standing in for every tab).
- **D10: an unplaced neighbour keeps the small target blocking.** The layout walker stops at depth 25 and at elements without a node id; when a neighbour of an undersized target has no rect, the circle test cannot be proven, so the target keeps a blocking warning naming the neighbour (verify round 1).
- Structure resolution now names every type a refused anatomy is realised on (`realisation: "web"` or `"mobile"`), the first part of FR-D1.

**Slice B: vocabulary (FR-B1 to FR-B10)**
- `src/types.ts:1-12`: `NodeType` gains `link`, `text-input`, `nav-item`, `tabs`, `tree-row`, `menu-row`, `property-row`, `divider`. New props on `SceneNode`: `ariaLabel`, `links`, `count`, `dotColor`, `active` (already on `TabItem`), `depth`, `collapsed`, `expanded`, `shortcut`, `destructive`, `submenu`, `labelWidth`, `placeholder`, `rows`, `error`, `orientation`, `inset`, and a `TabsItem` type for `tabs.items`, with `SceneNode.items` (`:294`) widened to `TabItem[] | TabsItem[]`. The `variant` union (`:296`) and `size` union (`:303`) widen; `title` (`:283`) extends to `text`.
- `src/primitives.ts`: `BUTTON_HEIGHT.xs = 24` and its padding and font; the new primitives' heights; `CLICKABLE_PRIMITIVES`, a superset of `TAPPABLE_PRIMITIVES` (`:24`), used by the web and desktop-app censuses.
Expand Down
35 changes: 32 additions & 3 deletions src/color-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,30 @@ export function tintLayer(

export type StatusColors = Record<'success' | 'warning' | 'danger', string>;

/** Phase 31 FR-A8 — the control colors an app needs: a focus ring that reads
* against every surface and tint it can sit on, and a border strong enough
* to be a control's only boundary cue (both 3:1, WCAG 1.4.11). */
export type ControlColors = Record<'focus-ring' | 'border-strong', string>;

/** First ramp step clearing `min` against EVERY background. Falls back to
* the extreme step (900 for light themes, 50 for dark), which clears against
* the generated surfaces. */
function pickStepAll(ramp: Ramp, bgs: string[], min: number, direction: 'darker' | 'lighter'): string {
const steps = direction === 'darker' ? [...RAMP_STEPS] : [...RAMP_STEPS].reverse();
for (const s of steps) {
if (bgs.every((bg) => contrastRatio(rgbOf(ramp[s]), rgbOf(bg)) >= min)) return ramp[s];
}
return direction === 'darker' ? ramp[900] : ramp[50];
}

function controlColors(primary: Ramp, neutral: Ramp, theme: SemanticTheme, tints: Record<string, string>, direction: 'darker' | 'lighter'): ControlColors {
const surfaces = [theme['bg-primary'], theme['bg-surface'], theme['bg-elevated']];
return {
'focus-ring': pickStepAll(primary, [...surfaces, ...Object.values(tints)], 3, direction),
'border-strong': pickStepAll(neutral, [theme['bg-primary'], theme['bg-surface']], 3, direction),
};
}

/** The full system from one seed — what generate_color_system writes/reports.
* The dark layer includes STATUS overrides (dogfood fix): the light-tuned
* status colors are re-lit for AA against the dark surface. */
Expand All @@ -313,8 +337,10 @@ export function generateColorSystem(seedHex: string): {
categorical: CategoricalPalette;
/** Phase 28 — the tint layer (accent/success/warning/danger/neutral-tint), light values. */
tints: TintLayer;
/** Dark counterparts for categorical + tints (merged into dark.colors by callers). */
darkRange: CategoricalPalette & TintLayer;
/** Phase 31 — focus-ring + border-strong, light values. */
controls: ControlColors;
/** Dark counterparts for categorical + tints + controls (merged into dark.colors by callers). */
darkRange: CategoricalPalette & TintLayer & ControlColors;
/** Present when the palette degraded deliberately (near-neutral seed, fewer hues). */
rangeNote?: string;
} {
Expand Down Expand Up @@ -347,12 +373,15 @@ export function generateColorSystem(seedHex: string): {
'danger-tint': darkStatus.danger,
'neutral-tint': darkFull['text-secondary'],
}, 'dark');
const controls = controlColors(primary, neutral, light, tints, 'darker');
const darkControls = controlColors(primary, neutral, darkFull, darkTints, 'lighter');
return {
seed: { hex: seedHex, oklch: hexToOklch(seedHex) }, primary, neutral, status, light,
dark: darkFull,
categorical: cat.palette,
tints,
darkRange: { ...cat.darkPalette, ...darkTints },
controls,
darkRange: { ...cat.darkPalette, ...darkTints, ...darkControls },
...(cat.note ? { rangeNote: cat.note } : {}),
};
}
Loading
Loading