From 6e8f7d33dc1064fb721800a052784f5f928c4dfc Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sat, 31 Oct 2026 20:59:47 +0100 Subject: [PATCH 1/3] Give genre its own provenance field and make data canonical apply_preset overwrote canvas_set_genre's stamp because both wrote provenance.preset. Genre now lives in provenance.genre, with a legacy read and migrate-on-write for stored canvases; data is the canonical genre and dashboard its alias. Phase 30 slice A. --- README.md | 14 ++-- VISION.md | 2 +- docs/GUIDELINES.md | 9 ++- docs/specs/PHASE-30-SPEC.md | 9 ++- src/evaluate.ts | 100 +++++++++++++++++------- src/genre.ts | 45 +++++++++++ src/index.ts | 32 ++++---- src/presets.ts | 5 ++ src/scene-graph.ts | 22 +++--- src/types.ts | 6 ++ src/viewer.ts | 24 +++--- test-build-log.ts | 6 +- test-calibration.ts | 2 +- test-discoverability.ts | 27 ++++++- test-eval-cache-key.ts | 42 ++++++++++- test-genre-report.ts | 44 +++++++++-- test-set-genre.ts | 146 +++++++++++++++++++++++++++++++++--- 17 files changed, 433 insertions(+), 102 deletions(-) create mode 100644 src/genre.ts diff --git a/README.md b/README.md index aff320e..71bbb7f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Every screen below was built end-to-end by an agent with framesmith's own vocabu ![A specialty-coffee checkout built by an agent in framesmith, light theme: a masthead with the store mark and a secure-checkout note, a form column with Contact, Delivery and Payment sections — real radio, checkbox and input controls, a two-option delivery-speed selector — beside a sticky order summary listing two line items, subtotal, free delivery, a first-order credit, the total, and a Place order button, with a gift-code card beneath. Generated from one seed (#A24B1E) on the "soft" personality and stamped with the commerce genre.](https://raw.githubusercontent.com/vicmaster/framesmith/master/docs/framesmith-checkout.png?v=2.1) -> Not every screen is a dashboard. This checkout carries the `commerce` genre — the transactional counterpart to `dashboard` — so the evaluator reads its line prices and total as the design rather than as fabricated data. It still cleared the same bar as the two screens above, including designed **empty-basket** and **loading** states. +> Not every screen is a dashboard. This checkout carries the `commerce` genre — the transactional counterpart to `data` — so the evaluator reads its line prices and total as the design rather than as fabricated data. It still cleared the same bar as the two screens above, including designed **empty-basket** and **loading** states. ``` MCP Client → stdio → framesmith server @@ -788,14 +788,14 @@ Comments are authored in the viewer: toggle **Comment** in the detail-page toolb ### `canvas_set_genre` -Durably declare what a canvas *is* — writes the evaluation genre to `metadata.provenance.preset` without the token/component churn of `apply_preset`, so an already-styled canvas can be calibrated in one call. The stamp is what `canvas_evaluate` / `canvas_autofix` / the viewer's quality panel read when no explicit `genre` param is passed. +Durably declare what a canvas *is* — writes the evaluation genre to `metadata.provenance.genre` without the token/component churn of `apply_preset`, so an already-styled canvas can be calibrated in one call. Genre and preset are separate fields: `apply_preset` never changes the genre, and a genre an older framesmith stored in the preset field moves to its own field on the first write. The stamp is what `canvas_evaluate` / `canvas_autofix` / the viewer's quality panel read when no explicit `genre` param is passed. | Param | Type | Description | |-------|------|-------------| | `canvasId` | string | Canvas to stamp | -| `genre` | string \| null | Genre to stamp (`"dashboard"`, `"commerce"`, `"material"`, …) — `null` clears the stamp | +| `genre` | string \| null | Genre to stamp (`"data"`, `"commerce"`, `"material"`, …) — `null` clears the stamp | -Returns `{ canvasId, genre, previous, relaxes, note?, versionHashUnchanged }` — `relaxes` lists the cliché tells the stamp relaxes; an unknown genre is stored but relaxes nothing, and the `note` says so immediately. Other provenance facts (structure, `importedFrom`) are preserved, and the stamp never moves the canvas's `versionHash` (the hash covers design content only) — recorded approvals stay valid. +Returns `{ canvasId, genre, canonical, previous, relaxes, presetRelaxes?, note?, versionHashUnchanged }` — `canonical` is the genre with its alias folded (`"dashboard"` → `"data"`, `"checkout"` → `"commerce"`, what evaluate reports); `relaxes` lists the cliché tells the stamp relaxes; `presetRelaxes` (`{ preset, relaxed }`) appears when the canvas's preset relaxes tells on its own (the material preset), which a genre clear doesn't remove; an unknown genre is stored but relaxes nothing, and the `note` says so immediately. Other provenance facts (structure, `importedFrom`) are preserved, and the stamp never moves the canvas's `versionHash` (the hash covers design content only) — recorded approvals stay valid. ### `canvas_evaluate` @@ -807,7 +807,7 @@ Open point-and-tell comments block too: when the canvas has any, the result carr A `FRAGILE` stress run blocks too. Once `canvas_stress` has recorded a verdict for the canvas, the result carries **`stress`**: `{ verdict, warnings, current }`, where `current` says whether that run was on the canvas's present `versionHash`. A `FRAGILE` verdict keeps the directive `NOT READY` even with zero evaluate issues, until a full `CLEAN` run (all five perturbations) replaces it; a subset run can record `FRAGILE` but never clear it. When `current` is false the directive tells you to re-run stress to confirm your fix. The verdict lives in server memory, so a restart forgets it. `canvas_evaluate` never runs stress itself. -Whenever the `cliche` category runs, the result also carries a **`genre`** field auditing the genre decision: `{ active, source, relaxed, notRelaxed }` — the genre in effect, whether it came from the `genre` param or the canvas's provenance stamp, the tells it relaxed, and `notRelaxed` (`[{ tell, relaxedBy }]`) — tells still flagging that a *different* genre would relax. A score pinned by tells listed in `notRelaxed` usually means the wrong genre was declared. +Whenever the `cliche` category runs, the result also carries a **`genre`** field auditing the genre decision: `{ active, source, relaxed, notRelaxed, byPreset? }` — the genre in effect (canonical: a `"dashboard"` stamp reports `"data"`, a `"checkout"` one `"commerce"`), whether it came from the `genre` param or the canvas's provenance stamp, the tells relaxed, `notRelaxed` (`[{ tell, relaxedBy }]`) — tells still flagging that a *different* genre would relax — and `byPreset` (`{ preset, relaxed }`), present when the applied preset relaxed tells on its own (the material preset relaxes accent-hue and pure-black-white whatever the genre). A score pinned by tells listed in `notRelaxed` usually means the wrong genre was declared. Whenever the `coverage` category runs on a **base** canvas, the result also carries a **`coverage`** field: `{ dataBearing, states, missing }` — whether the screen carries data-bearing content, which state variants (`canvas_add_variant`) already exist, and which demanded states are missing. It's absent on variant canvases (a variant *is* a designed state, so it doesn't demand states of itself). @@ -816,7 +816,7 @@ Whenever the `coverage` category runs on a **base** canvas, the result also carr | `canvasId` | string | Canvas ID to evaluate | | `mode` | `"fast"` \| `"detailed"` \| `"llm"` | `"fast"` = JSON-tree analysis only (<100ms). `"detailed"` adds Puppeteer-based pixel-level overlap checks, laid out at the canvas's artboard size. `"llm"` runs fast-mode heuristics plus a vision-model critique (provider picked from `FRAMESMITH_LLM_PROVIDER` or whichever of `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` is set — costs one paid API call per invocation). Default `"fast"`. | | `categories` | string[]? | Subset of `spacing`, `color`, `typography`, `structure`, `consistency`, `cliche`, `coverage`, `usability`. Defaults to all. | -| `genre` | string? | Style that relaxes specific `cliche` gates — `"material"` allows purple and white elevated surfaces; `"dashboard"` (alias `"data"`) allows realistic figures on data-dense screens; `"commerce"` (alias `"checkout"`) allows the money on a transactional screen — cart, checkout, order confirmation, billing history. Defaults to the canvas's provenance preset if stamped (`canvas_set_genre` stamps it durably, no token churn). Pick by what the screen is *for* (read screens with published figures → `dashboard`; a screen you complete a purchase on → `commerce`; editors/admin forms → `material`; a pricing page is marketing and gets none of them), not what it contains — the result's `genre` field audits the choice. | +| `genre` | string? | Style that relaxes specific `cliche` gates — `"material"` allows purple and white elevated surfaces; `"data"` (alias `"dashboard"`) allows realistic figures on a screen whose numbers are the user's own data (dashboards, activity, finance, fitness, logistics); `"commerce"` (alias `"checkout"`) allows the money on a transactional screen — cart, checkout, order confirmation, billing history. Defaults to the canvas's stored genre (`canvas_set_genre` stamps it durably, no token churn; `apply_preset` never changes it). Replaces only the genre: a material-preset canvas keeps the material relaxation. Pick by what the screen is *for* (read screens with the user's own figures → `data`; a screen you complete a purchase on → `commerce`; editors/admin forms → `material`; a pricing page is marketing and gets none of them), not what it contains — the result's `genre` field audits the choice. | **Categories and what they check** @@ -906,7 +906,7 @@ Runs `canvas_evaluate` in fast mode and returns just the subset of issues with a |-------|------|-------------| | `canvasId` | string | Canvas to autofix | | `categories` | string[]? | Restrict to fixes from these categories (default: all) | -| `genre` | string? | Style that relaxes specific `cliche` gates — `"material"` allows purple and white elevated surfaces; `"dashboard"` (alias `"data"`) allows realistic figures on data-dense screens; `"commerce"` (alias `"checkout"`) allows the money on a transactional screen — cart, checkout, order confirmation, billing history. Defaults to the canvas's provenance preset if stamped (`canvas_set_genre` stamps it durably, no token churn). | +| `genre` | string? | Style that relaxes specific `cliche` gates — `"material"` allows purple and white elevated surfaces; `"data"` (alias `"dashboard"`) allows realistic figures on a screen whose numbers are the user's own data (dashboards, activity, finance, fitness, logistics); `"commerce"` (alias `"checkout"`) allows the money on a transactional screen — cart, checkout, order confirmation, billing history. Defaults to the canvas's stored genre (`canvas_set_genre` stamps it durably, no token churn; `apply_preset` never changes it). Replaces only the genre: a material-preset canvas keeps the material relaxation. | | `apply` | bool? | Write the fixes to the canvas in this call (default `false`: propose only) | **What gets auto-fixed** diff --git a/VISION.md b/VISION.md index 8d4f45f..69612f6 100644 --- a/VISION.md +++ b/VISION.md @@ -545,7 +545,7 @@ _Full spec-driven breakdown in [`docs/specs/PHASE-29-SPEC.md`](docs/specs/PHASE- The mobile attempt (2026-09-07) built one iOS screen end to end on v2.2.0 and got 100/100, READY TO PRESENT, without a single mobile check running: the touch-target rule only looks at form controls, and the screen had none. Mobile today is a screen size, not a surface. This phase gives a canvas a declared `type` (`web` or `mobile`), separates it from genre and preset so the three declarations stop colliding, and builds the three layers a surface needs: rules that judge a phone, vocabulary to build one, and starting points. Acceptance is literal: the attempt screen rebuilt from a mobile archetype, CLEAN with every tap target a primitive. -- [ ] Slice A — untangle the declarations: genre gets its own field so `apply_preset` stops overwriting it, every stored canvas keeps its verdict through a legacy read, and `data` becomes the canonical purpose genre with `dashboard` as its alias +- [x] Slice A — untangle the declarations: genre gets its own field so `apply_preset` stops overwriting it, every stored canvas keeps its verdict through a legacy read, and `data` becomes the canonical purpose genre with `dashboard` as its alias - [ ] Slice B — the type axis: `metadata.surface` with one device profile (`iphone-390`), `canvas_create({ type })` and `canvas_set_type`, a ruleset table keyed by type, genre and preset that composes by union and can turn rules on, a `surface` report on every evaluation, and a mobile type scale from `generate_design_system` - [ ] Slice C — the mobile ruleset: 44px targets as an error on every tappable primitive, safe areas, a type floor, undeclared phone-width canvases told to declare, hand-built tap targets and multi-column prose named as advisories, and stress that knows the type - [ ] Slice D — vocabulary: `fixed` / `sticky` positioning, `safeArea` on the root, and the `status-bar`, `home-indicator`, `nav-bar`, `tab-bar`, `button`, `list-row` and `sheet` primitives diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 30b0f62..fea24e4 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -310,7 +310,7 @@ Polishing the design to the bar is **your** job, not the user's — they should **Readiness turns on blocking findings, not the score.** `READY` means step 4 is done — zero errors, zero warnings, zero cliché tells — and no unresolved `FRAGILE` stress verdict; nothing more. The score (aim for **> 95**) is always reported alongside the directive and is still the quality bar you're climbing toward, but it no longer withholds readiness by itself: a design with nothing left to resolve is `READY` even sitting at or below the bar, and the directive says so explicitly, pointing at the remaining advisory findings as the lever. (Before Phase 29 slice D, readiness also required score > 95, which could strand a design with every real problem fixed and no honest move left to raise the number — a gate that can't be satisfied by improving the design isn't a gate.) -**Which number is the gate?** The heuristic directive (fast mode) is the presentation gate — always available, no API key. `mode: "llm"` adds a vision-model rubric critique (composition, hierarchy, polish) on top; it needs `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` and degrades gracefully without one — when it's unavailable, the heuristic directive alone decides. With a key the heuristic directive still decides, and a `needsRevision: true` verdict never blocks presenting. Run the critique loop as the standard last step (judge, `canvas_revise`, re-judge). If `canvas_revise` stops (`max-iterations` or `no-improvement`) with axes still failing, present the design and tell the user which axes fail rather than looping. And calibrate the evaluator to what the screen *is*: a data-dense product screen evaluated without `genre: "dashboard"` — or a checkout without `genre: "commerce"` — will flag its own figures as fabricated and pin the score below the bar with no path up — that's a miscalibrated gate, not a bad design (see Cliché & craft below). +**Which number is the gate?** The heuristic directive (fast mode) is the presentation gate — always available, no API key. `mode: "llm"` adds a vision-model rubric critique (composition, hierarchy, polish) on top; it needs `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` and degrades gracefully without one — when it's unavailable, the heuristic directive alone decides. With a key the heuristic directive still decides, and a `needsRevision: true` verdict never blocks presenting. Run the critique loop as the standard last step (judge, `canvas_revise`, re-judge). If `canvas_revise` stops (`max-iterations` or `no-improvement`) with axes still failing, present the design and tell the user which axes fail rather than looping. And calibrate the evaluator to what the screen *is*: a screen whose figures are the user's own data evaluated without `genre: "data"` — or a checkout without `genre: "commerce"` — will flag its own figures as fabricated and pin the score below the bar with no path up — that's a miscalibrated gate, not a bad design (see Cliché & craft below). ## Cliché & craft @@ -322,7 +322,7 @@ Polishing the design to the bar is **your** job, not the user's — they should | **Gradient / glow overuse** | 3+ gradient nodes, or a colored glow/bloom shadow (large blur + a saturated or translucent-white color) | Flat `$surface` fills. Reserve a gradient for at most one deliberate focal moment; use a subtle near-black low-alpha shadow, not a halo. | | **Fake browser / OS chrome** | A row of ≥3 small circular dots (mac traffic lights) wrapping content | Frame the content directly. Skip the fake window — it adds nothing and dates the mockup. | | **Hanging eyebrow header** | A small eyebrow/tag *beside* a large heading in a horizontal row | Stack the eyebrow **above** the heading (`layout: "vertical"`, left-aligned). | -| **Fabricated content** | Invented metrics / testimonials / brand logos in placeholder copy (`"99.9% uptime"`, `"— Jane Doe, CEO"`, `"TechCrunch"`) | Use a labeled placeholder until real data exists: `"Uptime — to confirm"` + a neutral block. Don't ship invented numbers. **Exception:** on a dashboard/analytics mockup the realistic figures ARE the design — pass `genre: "dashboard"` (alias `"data"`) so they aren't flagged; on a transactional screen (cart, checkout, order confirmation, billing history) the money is the design — pass `genre: "commerce"` (alias `"checkout"`). A pricing page gets neither: its numbers are claims. | +| **Fabricated content** | Invented metrics / testimonials / brand logos in placeholder copy (`"99.9% uptime"`, `"— Jane Doe, CEO"`, `"TechCrunch"`) | Use a labeled placeholder until real data exists: `"Uptime — to confirm"` + a neutral block. Don't ship invented numbers. **Exception:** on a screen whose figures are the user's own data (a dashboard, activity, finance, fitness or logistics screen) the realistic figures ARE the design — declare `genre: "data"` (alias `"dashboard"`) so they aren't flagged; on a transactional screen (cart, checkout, order confirmation, billing history) the money is the design — pass `genre: "commerce"` (alias `"checkout"`). A pricing page gets neither: its numbers are claims. | | **Eyebrow rhythm** | More eyebrow labels than ~1 per 3 sections — an eyebrow above nearly every heading. An eyebrow is small text that is uppercase (via `textTransform` **or** typed in capitals) or tracked at `letterSpacing` ≥ 1 | Keep eyebrows rare (≤ `ceil(sections / 3)`). Let most headings stand alone; reserve the eyebrow for sections that genuinely need a kicker. A form label with the light tracking a type role sets (0.25–0.6) is not an eyebrow and doesn't count. | | **Slop copy** | Stock AI phrasing in short copy — filler verbs (`"Elevate"`, `"Seamless"`, `"Unleash"`), scroll cues (`"Scroll to explore"`), placeholder names (`"Jane Doe"`), hype labels (`"BETA"`, `"Early access"`), section-number eyebrows (`"01 / Index"`) | Write specific, branded copy that names the concrete benefit. A numeral bound to a unit noun by a tight hyphen is a compound modifier, not an eyebrow — `"30-day returns"`, `"2-year warranty"` and `"24/7 support"` are ordinary product copy and pass. | | **Radius consistency** | 4+ distinct corner radii across the page — no single radius system (full pills, `cornerRadius: 999`+, are a shape choice and never count toward the census) | Consolidate to one small radius scale (e.g. `8` / `12`, plus `999` for pills). Define it as a `$token` and reuse. | @@ -331,8 +331,9 @@ Polishing the design to the bar is **your** job, not the user's — they should - **`cliche` tells are `warning`/`info`, never a hard error — but every one blocks the directive.** They dent the score and they hold back `READY` until each is resolved; they're the slop signal, so fix them rather than reading "info" as optional. - **`canvas_autofix` fixes the mechanical ones** — it swaps a *known-default* purple accent, deletes a fake-chrome strip, and softens pure black (`#000000`) ink to off-black, and corrects a misspelled icon name when exactly one spelling resolves. Gradient/glow, the hanging header, fabricated copy, eyebrow rhythm, slop copy, mixed radius systems, and competing accents carry a suggestion but no op (taste/judgment calls). -- **Genre relaxes intentional tells** — stamp the canvas once with `canvas_set_genre` (no token churn; `null` clears; the stamp never moves the `versionHash`, so approvals stay valid), or pass `genre` per evaluate/autofix call, so a style that legitimately uses a tell isn't nagged. `genre: "material"` allows the purple accent **and** white elevated surfaces (both are intentional in Material Design); `genre: "dashboard"` (alias `"data"`) allows realistic figures on data-dense product screens, and `genre: "commerce"` (alias `"checkout"`) allows the money on a transactional screen — cart, checkout, order confirmation, billing history (both relax `honest-content`). Declare the genre the screen actually **is** — don't use it to dodge flags on a marketing page. -- **Genre follows what the screen is FOR, not what it contains.** "It shows figures → `dashboard`" is the intuitive heuristic and it's wrong: a *targets editor* stamped `dashboard` clears one figure tell while leaving the white-surface tells that `material` would have relaxed. Read screens presenting published figures → `dashboard`/`data`; a screen you complete a purchase on → `commerce`/`checkout`; editors and admin forms → `material`. A **pricing page is marketing**, not commerce: its numbers are claims about a product, not the arithmetic of a transaction, so it keeps the fabricated-content gate. The evaluate result's `genre` field makes this checkable: it reports the active genre, its source (`explicit` param vs `provenance` stamp), the tells it `relaxed`, and `notRelaxed` (`[{ tell, relaxedBy }]`) — tells still flagging that a *different* genre would relax. If your score is pinned by tells listed in `notRelaxed`, you likely declared the wrong genre. (`honest-content` is effectively the ceiling for any financial UI — declare `dashboard` on read screens, `commerce` on transactional ones, or the design's own figures cap the score.) +- **Genre relaxes intentional tells** — stamp the canvas once with `canvas_set_genre` (no token churn; `null` clears; the stamp never moves the `versionHash`, so approvals stay valid), or pass `genre` per evaluate/autofix call, so a style that legitimately uses a tell isn't nagged. `genre: "material"` allows the purple accent **and** white elevated surfaces (both are intentional in Material Design); `genre: "data"` (alias `"dashboard"`) allows realistic figures on any screen whose numbers are the user's own data — dashboards, activity, finance, fitness, logistics — and `genre: "commerce"` (alias `"checkout"`) allows the money on a transactional screen — cart, checkout, order confirmation, billing history (both relax `honest-content`). Declare the genre the screen actually **is** — don't use it to dodge flags on a marketing page. +- **Genre and preset are separate fields.** `canvas_set_genre` writes `metadata.provenance.genre`; `apply_preset` writes `metadata.provenance.preset` and never changes the genre, so styling a declared canvas can't undo its declaration. The **material preset relaxes on its own**: a canvas styled with `apply_preset("material")` skips the accent-hue and pure-black-white tells whatever its genre, even under an explicit `genre` param (which replaces only the genre). The evaluate result's `genre.byPreset` (`{ preset, relaxed }`) shows when that happened, and `canvas_set_genre`'s `presetRelaxes` says so at stamp time — clearing the genre doesn't remove a preset's relaxation; applying another preset does. Aliases fold: `genre.active` and `canvas_set_genre`'s `canonical` report `"data"` for a `"dashboard"` stamp and `"commerce"` for `"checkout"` (the stored value stays as you wrote it). Canvases stamped by an older framesmith (genre stored in the preset field) keep their score; the first `canvas_set_genre` or `apply_preset` moves the genre to its own field. Run one framesmith version per repo: an older version writes the genre into the preset field, which this one ignores once a canvas has been migrated. +- **Genre follows what the screen is FOR, not what it contains.** "It shows figures → `data`" is the intuitive heuristic and it's wrong: a *targets editor* stamped `data` clears one figure tell while leaving the white-surface tells that `material` would have relaxed. Read screens presenting the user's own figures → `data`/`dashboard`; a screen you complete a purchase on → `commerce`/`checkout`; editors and admin forms → `material`. A **pricing page is marketing**, not commerce: its numbers are claims about a product, not the arithmetic of a transaction, so it keeps the fabricated-content gate. The evaluate result's `genre` field makes this checkable: it reports the active genre (canonical), its source (`explicit` param vs `provenance` stamp), the tells it `relaxed`, and `notRelaxed` (`[{ tell, relaxedBy }]`) — tells still flagging that a *different* genre would relax. If your score is pinned by tells listed in `notRelaxed`, you likely declared the wrong genre. (`honest-content` is effectively the ceiling for any financial UI — declare `data` on read screens, `commerce` on transactional ones, or the design's own figures cap the score.) ## Sharp edges diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index 80f8698..c3564c2 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -157,7 +157,13 @@ Values marked medium came from secondary sources because Apple's HIG pages are J - `src/index.ts:1544-1549` (`apply_preset`): still writes only `preset`, after the migration below; genre is no longer clobbered because resolution reads `genre` first. - One exported `resolveGenre(canvas, explicit?)`: `explicit ?? prov.genre ?? (isPresetName(prov.preset) ? undefined : prov.preset)`. Callers: `src/evaluate.ts:2114`, the `canvas_autofix` path at `index.ts:2075-2086`, and the viewer at `src/viewer.ts:872` (which must stop passing `preset` as an explicit genre). `evalCacheKey` at `viewer.ts:848-849` keys on resolved genre, preset and type. - `setCanvasGenre` and the `apply_preset` write both call one `migrateLegacyGenre(prov)` first (FR-A1's migrate-on-write). -- `src/evaluate.ts:1018-1052`: swap canonical and alias for `data`/`dashboard`. FR-A2 (the material relaxation behind the preset key) lands in slice B with the ruleset table. +- `src/evaluate.ts:1018-1052`: swap canonical and alias for `data`/`dashboard`. The ruleset-table refactor of FR-A2 lands in slice B. +- **As built (spec changes recorded at slice A):** + - *The `genre` key is the migration marker.* `Provenance.genre` is `string | null`. A provenance with the key present (null meaning "no genre") is migrated, and its `preset` is always a preset; the legacy read applies only when the key is absent. `migrateProvenance` writes the genre, or `genre: null`, on every `canvas_set_genre` / `apply_preset`. Without a marker, a preset imported by `import_design_md` under any name other than the four built-ins cannot be told apart from a legacy genre, and migration would durably relabel it on the next write. `isPresetName` is the fixed built-in set (`BUILTIN_PRESET_NAMES` in `src/presets.ts`), never the runtime registry, whose contents differ between the MCP server and the standalone viewer. + - *The preset half of FR-A2 lands in slice A.* `RELAXED_BY_PRESET = { material: [...] }` is unioned into the relaxation whatever the genre source is, explicit included. Without it, slice A alone would strip every material-preset canvas of a relaxation it has today. The genre report gains an additive `byPreset: { preset, relaxed }` so `active: null` with a non-empty `relaxed` is explained, and `canvas_set_genre` returns `presetRelaxes` for the same reason (clearing the genre does not remove a preset's relaxation). + - *Every alias canonicalises.* `genre.active` reports `commerce` for a `checkout` stamp as well as `data` for `dashboard`; stored values and `canvas_set_genre`'s `previous` stay verbatim, and its result adds `canonical`. + - *Genre lookups are own-key only.* A stored genre is free text; `"constructor"` is an unknown genre, not a crash. + - *The viewer's provenance chip* shows structure · canonical genre · preset (the preset only when it is a built-in name). - Surfaces: every `"dashboard"` genre recommendation: `src/index.ts:135` and `:146` (GOTCHAS), `:1966` and `:1971` (`canvas_set_genre` docstring and param), `:2000` (`canvas_evaluate`), and the honest-content suggestion at `src/evaluate.ts:1391`; plus the genre passages of `docs/GUIDELINES.md` and README. `test-discoverability.ts:118-130` parses only the relax-genre keys, so it will not catch these; the slice adds a check that no agent surface recommends `genre: "dashboard"`. - Tests: `test-set-genre.ts` and `test-genre-report.ts` (preset no longer clobbers genre; legacy read for a known genre, an unknown genre and `material`; migrate-on-write; clearing a legacy genre), `test-cliche.ts` (alias swap, report names `data`), a viewer-vs-evaluate score parity case. @@ -229,6 +235,7 @@ Slice order: A → B → C → D → E → F → G. A untangles the field B's ru - **A declaration is still on the honour system.** FR-C6 closes the silent path (an undeclared phone-width canvas warns), but an agent can still declare `web` explicitly on a phone app screen to avoid the mobile errors, as it could borrow a genre before Phase 29. The remaining mitigation is visibility: the `surface` report is in every evaluate result, and the viewer frames only declared mobile canvases, so a misdeclared phone screen looks wrong to the human. Preventing it outright would need width-based inference, which would make a responsive web page designed at 390 impossible to express. - **The gate covers primitives, not every pixel.** FR-C1 blocks on every tappable primitive; FR-C2 only advises on hand-built frames. A screen hand-built entirely from frames can still pass with small targets. This is a deliberate trade (C5), and it holds only if the archetypes really are the default path, which is why FR-E4 gates them and FR-G1 counts primitives. +- **Mixed framesmith versions on one repo.** An older framesmith still writes a genre into `preset`. On a canvas this version has migrated, `genre` wins, so an older server's `canvas_set_genre` is ignored here, and an older server reads this version's `preset` as its genre. A repo's collaborators should run the same framesmith minor; GUIDELINES says so. - **Moving the genre field touches stored data.** The legacy read in FR-A1 is defined negatively (anything that is not a real preset name is a genre), so unknown genres written by the old `canvas_set_genre` survive. Preset names stored by the old `canvas_set_genre` (`material`, `dark`, `light`, `minimal`) are read as presets: `material` keeps its relaxation through the preset ruleset and the others relaxed nothing anyway, so no verdict changes; only `genre.active` goes to null, which is the untangling working. One accepted cost: a `material` stamp written by the old `canvas_set_genre` is now a preset, so `canvas_set_genre(null)` no longer removes its relaxation; applying another preset does. - **Stress stays advisory and in memory.** A phone canvas that was never stressed is not blocked, and a server restart forgets a FRAGILE verdict. Both are true on web today; FR-C8 does not make them worse, and persisting the verdict is a follow-up, not this phase. - **Fast and detailed modes can disagree on safe areas.** With `safeArea` on the root the default path is correct in both, and the disagreement narrows to positioned content nested in positioned frames, which fast mode cannot place. Fast mode flags that it cannot see it, detailed is authoritative, and the docs say so in the sentence that introduces the rule. diff --git a/src/evaluate.ts b/src/evaluate.ts index 117c034..027a0db 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -5,6 +5,7 @@ import { renderToHtml } from './renderer.js'; import { computeLayout, type LayoutRect } from './screenshot.js'; import { extractInventory, expandInstances, isDataLike } from './drift.js'; import { getIconSvg, parseIconRef } from './icons.js'; +import { storedGenre } from './genre.js'; // --- Types --- @@ -100,19 +101,25 @@ export interface EvaluationResult { } export interface GenreReport { - /** The genre the cliche checks ran under, or null when none was set. */ + /** The genre the cliche checks ran under (canonical: an alias like + * "dashboard" reports as "data"), or null when none was set. */ active: string | null; /** Where the active genre came from: the explicit `genre` option, the canvas - * provenance stamp (metadata.provenance.preset), or null when unset. */ + * provenance stamp (metadata.provenance.genre, or a legacy genre stored in + * provenance.preset), or null when unset. */ source: 'explicit' | 'provenance' | null; - /** Tells the active genre relaxed (skipped entirely). Empty when the active - * genre is unknown to the relaxation table — a signal the stamp isn't doing - * what the author may think. */ + /** Tells skipped entirely, by the genre and by the applied preset. Empty + * when neither relaxes anything — for a genre stamp, a signal the stamp + * isn't doing what the author may think. */ relaxed: ClicheTell[]; /** Tells that stayed ACTIVE but would be relaxed by some other genre — - * e.g. under "dashboard", accent-hue/pure-black-white still flag but + * e.g. under "data", accent-hue/pure-black-white still flag but * "material" would relax them. */ notRelaxed: Array<{ tell: ClicheTell; relaxedBy: string[] }>; + /** Phase 30 — present when the canvas's applied preset relaxed tells on its + * own (the material preset), so `relaxed` is explained even when `active` + * is null. */ + byPreset?: { preset: string; relaxed: ClicheTell[] }; } interface NodeEntry { @@ -1013,18 +1020,22 @@ function checkConsistencyDetailed(entries: NodeEntry[], layoutRects: LayoutRect[ // fake browser chrome, the hanging eyebrow header, fabricated data). Advisory // by design (warning/info, never a hard error); genre can relax a gate. -/** Genre (active preset / design system) → tells it relaxes because they're - * intentional in that style. Material Design is legitimately purple. */ +/** Genre (what the screen is for) → tells it relaxes because they're + * intentional in that kind of screen. Material Design is legitimately purple. */ const RELAXED_BY_GENRE: Record = { // Material Design legitimately uses a purple accent AND white elevated // surfaces (cards on an off-white background), so both are intentional here. + // Also a preset (RELAXED_BY_PRESET); kept as a genre because agents are + // told to declare editors and admin forms as "material". material: ['accent-hue', 'pure-black-white'], - // Phase 22 slice C (#135) — data-dense product screens: realistic figures + // Phase 22 slice C (#135) — screens whose figures are the user's own data + // (dashboards, activity, finance, fitness, logistics): realistic figures // ARE the design (they drive column widths, wrapping, $2.7M-vs-$2,700,000 // legibility), so "fabricated data" flags would zero the category for the - // screen's whole reason to exist. `data` is an alias. - dashboard: ['honest-content'], + // screen's whole reason to exist. Phase 30: `data` is canonical (it names + // what the numbers are, not a layout); `dashboard` is the alias. data: ['honest-content'], + dashboard: ['honest-content'], // Phase 29 slice A (#194) — transactional commerce screens: a checkout's // line prices, subtotal, store credit and total ARE the design (they set the // column widths, the decimal alignment, and which number reads as the one @@ -1040,28 +1051,61 @@ const RELAXED_BY_GENRE: Record = { checkout: ['honest-content'], }; -/** Genres that are aliases of another entry — folded out of the genre report - * so an alias never reads as a second option. */ -const GENRE_ALIASES = new Set(['data', 'checkout']); +/** Phase 30 — the preset half of the ruleset: applying the material STYLE + * relaxes material's tells whatever the genre is, so untangling genre from + * preset doesn't strip a material-preset canvas of its relaxation. */ +const RELAXED_BY_PRESET: Record = { + material: ['accent-hue', 'pure-black-white'], +}; + +/** Genres that are aliases of another entry — folded to their canonical + * genre in the report so an alias never reads as a second option. */ +const GENRE_ALIASES = new Map([['dashboard', 'data'], ['checkout', 'commerce']]); + +// Own-key lookups only: a stored genre is free text, and "constructor" must +// read as an unknown genre, not Object.prototype.constructor. +function lookup(table: Record, key: string | undefined): ClicheTell[] { + return key !== undefined && Object.hasOwn(table, key) ? table[key] : []; +} + +export function canonicalGenre(genre: string): string { + return GENRE_ALIASES.get(genre) ?? genre; +} /** Issue #162 — what a genre stamp buys, for canvas_set_genre's immediate * feedback (the evaluate-time report stays buildGenreReport's job). */ export function relaxedByGenre(genre: string): ClicheTell[] { - return RELAXED_BY_GENRE[genre] ?? []; + return lookup(RELAXED_BY_GENRE, genre); +} +export function relaxedByPreset(preset: string | undefined): ClicheTell[] { + return lookup(RELAXED_BY_PRESET, preset); } export function knownGenres(): string[] { return Object.keys(RELAXED_BY_GENRE); } +/** Phase 30 — the one genre resolution every evaluator shares (canvas_evaluate, + * canvas_autofix, the viewer): the explicit option, else the canvas's stored + * genre (incl. the legacy read of `preset`), folded to its canonical name. */ +export function resolveGenre(canvas: Canvas, explicit?: string): { genre: string | undefined; source: GenreReport['source'] } { + if (explicit !== undefined) return { genre: canonicalGenre(explicit), source: 'explicit' }; + const stored = storedGenre(canvas.metadata?.provenance); + return stored !== undefined ? { genre: canonicalGenre(stored), source: 'provenance' } : { genre: undefined, source: null }; +} + /** Issue #152 — make the genre decision visible in the evaluate result: which * genre ran, where it came from, what it relaxed, and which tells a DIFFERENT * genre would have relaxed (the tradeoff that's otherwise only discoverable by - * watching the score move). Aliases (`data`, `checkout`) are folded into their - * canonical genre in relaxedBy so they don't read as separate options. */ -function buildGenreReport(genre: string | undefined, explicit: boolean, relaxed: Set): GenreReport { + * watching the score move). Aliases (`dashboard`, `checkout`) are folded into + * their canonical genre in relaxedBy so they don't read as separate options. */ +function buildGenreReport( + resolved: { genre: string | undefined; source: GenreReport['source'] }, + relaxed: Set, + preset: string | undefined, +): GenreReport { const notRelaxed = new Map(); for (const [g, tells] of Object.entries(RELAXED_BY_GENRE)) { - if (GENRE_ALIASES.has(g)) continue; // `data` → dashboard, `checkout` → commerce + if (GENRE_ALIASES.has(g)) continue; for (const tell of tells) { if (relaxed.has(tell)) continue; const by = notRelaxed.get(tell) ?? []; @@ -1069,11 +1113,13 @@ function buildGenreReport(genre: string | undefined, explicit: boolean, relaxed: notRelaxed.set(tell, by); } } + const byPreset = relaxedByPreset(preset); return { - active: genre ?? null, - source: explicit ? 'explicit' : genre !== undefined ? 'provenance' : null, + active: resolved.genre ?? null, + source: resolved.source, relaxed: [...relaxed], notRelaxed: [...notRelaxed].map(([tell, relaxedBy]) => ({ tell, relaxedBy })), + ...(byPreset.length ? { byPreset: { preset: preset!, relaxed: byPreset } } : {}), }; } @@ -1388,7 +1434,7 @@ function tellHonestContent(ctx: ClicheCtx): EvaluationIssue[] { nodeId: node.id, nodeName: node.name, message: `"${text.slice(0, 40)}" looks like fabricated data (${reason}).`, - suggestion: `Use a labeled placeholder until real data exists, e.g. "