diff --git a/README.md b/README.md index d17790c..fc54b45 100644 --- a/README.md +++ b/README.md @@ -803,7 +803,7 @@ Whenever the `coverage` category runs on a **base** canvas, the result also carr | `spacing` | 20 | Off-scale padding/gap values, plus spacing variety measured against the canvas's **declared** scale (`set_variables`/`generate_scale` spacing tokens) — only values *not* on that scale count toward sprawl, so a design built entirely from its own multi-step scale (framesmith's generated scale ships nine: `space-3xs`…`space-3xl`) isn't penalized for using it. No declared scale falls back to the old total-unique-values count, with a suggestion to declare one | | `color` | 25 | WCAG AA contrast ratios for text against nearest background — checked in BOTH themes when a `dark` token layer exists (dark-run issues carry `theme: "dark"`, score takes the worse theme, and dark failures point at the dark layer instead of carrying a literal fix). WCAG-passing pairs that are perceptually weak by **APCA** get an info advisory (Lc vs ~75 body / ~60 large) — APCA is a candidate method, not a standard; it never blocks | | `typography` | 20 | Type-scale ratios (1.1–2.0; adjacent sizes both declared as typography tokens are pinned and skip the check), font-family count, weight variation — plus score-neutral craft advisories: measure (prose past ~75 characters per line; warning past 90), tracking (display text ≥28px without negative letter-spacing, autofixable), and tabular numerals (numeric table columns without `tabularNums`, autofixable) | -| `structure` | 15 | Tree depth, naming coverage, design-token usage %, component reuse | +| `structure` | 15 | Tree depth, naming coverage, design-token usage %, component reuse, unknown icon names (an icon that resolves in neither set renders an empty slot: a directive-blocking warning, −10 structure score each; autofixable when one spelling resolves) | | `consistency` | 20 | Frames missing `layout`, sibling overlap (detailed mode) — plus score-neutral advisories: **token detachment** (a literal `fill`/`stroke`/`color`/`cornerRadius` that exactly equals a token's value flags with a mechanical `$ref` autofix; ambiguous matches list candidates and never guess) and **ad-hoc motion** (3+ distinct duration/easing combos with no motion tokens declared) | | `cliche` | 15 | Machine-made tells: default purple/indigo accent, gradient/glow overuse, fake browser/OS chrome (traffic-light dots), the hanging eyebrow-beside-heading header, fabricated metrics/testimonials/logos, eyebrow rhythm (an eyebrow above nearly every section), slop copy (stock AI phrasing — filler verbs, scroll cues, placeholder names, hype labels), radius consistency (too many distinct corner radii), pure black/white (`#000000` ink / `#ffffff` page vs off-black/off-white), accent consistency (multiple competing accent hues — the `$success`/`$warning`/`$danger` status vocabulary is exempt when referenced as tokens, since those are semantics rather than a second accent; the same hues as literals still count). Each issue carries a `tell` discriminator; all advisory (warning/info). Relaxable per `genre`. The tells know dashboard vocabulary: a violet reached only through a `$chart-*` or `$*-tint` token doesn't count as the default-purple tell (a literal violet, or one through any other token, still does), a gradient reached through a `$token` doesn't count toward gradient overuse, thin rounded sparkline bars aren't mistaken for traffic-light dots (fake-chrome now requires a near-square shape), and an uppercase label sitting beside a large tabular figure reads as a KPI-card metric, not an eyebrow. It also knows retail copy: a numeral bound to a unit noun by a tight hyphen (`"30-day returns"`, `"2-year warranty"`, `"24/7 support"`) is a compound modifier, not a numbered section eyebrow. | | `coverage` | 10 | State coverage on **base** canvases with data-bearing content: a detected table demands designed `empty` + `loading` variants, a form (3+ input controls) demands `error` — each missing state is a directive-blocking warning whose suggestion names the `canvas_add_variant` + scaffold path. Variant canvases and non-data screens get no findings. Table detection needs three matching horizontal rows to read as data (two stacked rows are treated as a layout, e.g. a topbar over a greeting); an import-named `Table` frame keeps the looser two-row shape. On a **`loading`** variant itself, a warning fires per data surface (a detected table, or a frame with 2+ large tabular/numeric texts) that still shows live values while a sibling region is skeletoned — "Loading state is half-designed" — naming the region and suggesting the matching skeleton scaffold. The result's `coverage` field reports `{ dataBearing, states, missing }`. | @@ -889,6 +889,7 @@ Runs `canvas_evaluate` in fast mode and returns just the subset of issues with a - **Consistency** — frames with multiple children but no `layout` get `layout: "vertical"`; a literal `fill`/`stroke`/`color`/`cornerRadius` that exactly equals a single token's value gets re-pointed to `$token` (a value shared by multiple tokens is reported with candidates but never auto-fixed — no guessing). - **Color** — recoverable WCAG contrast failures get `color: "#000000"` or `"#FFFFFF"`, whichever wins against the resolved background. Failures so bad that neither black nor white meets the threshold are not auto-fixed (the background also needs to change). - **Cliché** — a *known-default* purple/indigo accent (`#6366f1` and friends) written literally on a node swaps to a neutral accent; a dedicated fake-chrome strip (a row that is just traffic-light dots) gets a `D(...)` delete; pure-black ink (`#000000` text/icon/stroke) softens to off-black. Taste-dependent tells (gradient/glow overuse, the hanging header, fabricated copy, eyebrow rhythm, slop copy, mixed radius systems, competing accents) are reported by `canvas_evaluate` with a suggestion but carry **no** auto-fix op. +- **Structure** — an unknown icon name with exactly one spelling that resolves (hyphen ↔ underscore, or a missing `material:` prefix; if several resolve, the one matching the icon set the rest of the canvas uses) is rewritten via `U(id, { icon: "" })`. Icons inside component definitions are flagged but carry no fix op — edit the component. A name with no resolving spelling is reported with a suggestion only. - **Coverage** — missing state variants (empty/loading/error) have no mechanical fix either — designing a state is a judgment call. `canvas_evaluate` reports the gap with a suggestion naming the `canvas_add_variant` + scaffold path. **Return shape** @@ -1050,8 +1051,11 @@ I("parent", { type: "icon", icon: "heart", iconSize: 32, iconColor: "#ef4444" }) I("parent", { type: "icon", icon: "material:check", iconSize: 24, iconColor: "#b71421" }) I("parent", { type: "icon", icon: "material:settings", iconStyle: "rounded" }) I("parent", { type: "icon", icon: "material:star-fill" }) # "-fill" suffix = filled variant +I("parent", { type: "icon", icon: "material:chevron_right" }) # multi-word names keep Material's underscores ``` +Spelling differs by set: Lucide hyphenates (`chevron-right`), Material keeps underscores and needs the prefix (`material:chevron_right`). An icon name that resolves in neither set renders an empty slot, so `canvas_evaluate` warns on it (structure category) and `canvas_autofix` rewrites it when exactly one spelling resolves. + `iconStyle` picks the Material variant (`"outlined"` default, `"rounded"`, `"sharp"`); it's ignored for Lucide. ## Input controls diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 6d1b6b6..daea7e6 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -107,6 +107,7 @@ The `chart` node owns the value→coordinate math — give it data and domains, - **Setting `fontFamily` on every text node.** The renderer defaults to a system sans-serif stack at the body level. Only set `fontFamily` when you want a *different* face — and prefer `system-ui, -apple-system, sans-serif`-style stacks; if you need quoted multi-word names (`'Segoe UI'`), they're supported, but keep them inside the double-quoted value. - **Hardcoding pixel font sizes everywhere.** Large sizes get a `clamp()` treatment by the renderer to scale down on small viewports — only set `fontSize` to the *desktop* value and let the renderer handle the rest. - **Unicode glyphs as icon stand-ins.** `✓ ● ▾ ○` read as unfinished. Use the `icon` node type — two sets render by name as inline SVG: 1,900+ [Lucide](https://lucide.dev) (`icon: "check"`) and 3,800+ [Material Symbols](https://fonts.google.com/icons) (`icon: "material:check"`, `iconStyle: "outlined" | "rounded" | "sharp"`, `-fill` suffix for filled variants). `I("parent", { type: "icon", icon: "material:check", iconSize: 16, iconColor: "$primary" })`. For Material-style design systems, prefer the Material set. +- **Spelling an icon in the other set's style.** Lucide hyphenates multi-word names (`chevron-right`); Material Symbols keeps underscores and needs the prefix (`material:chevron_right`, `material:chevron_right-fill`). A bare `chevron_right` is looked up in Lucide and renders nothing. A name that resolves in neither set renders an empty slot, so `canvas_evaluate` reports it as a `structure` warning naming the spellings that do resolve — and `canvas_autofix` rewrites it when exactly one does. - **Baking uppercase into `content`.** Use `textTransform: "uppercase"` (with `letterSpacing` for tracking) so the underlying copy stays editable and case-styling lives in the design, not the data. - **Faking form controls from frames + ellipses.** `toggle`, `checkbox`, `radio`, and `select` are real node types: `I("parent", { type: "toggle", checked: true })`. They style themselves from `$accent`/`$border`/`$bg-surface` tokens (neutral fallbacks when unthemed) and stay pixel-consistent; `fill`/`stroke`/`color` override. - **Faking loading states from plain gray rectangles.** Use `{ type: "skeleton" }` — token-derived fill, correct default radius, and it pulses in the live viewer (always static in screenshots/exports so diffs stay deterministic). Stamp `skeleton-table` / `skeleton-card` / `skeleton-stat-card` instead of hand-placing bars — and skeleton EVERY data surface: the coverage check flags a loading variant that leaves live metric values beside skeletoned regions (a half-designed loading screen flashes real-looking data next to placeholders). @@ -237,8 +238,8 @@ Exit codes are gate-friendly for both commands: `0` pass, `1` drift/mismatch, `2 ## After designing -- **`canvas_evaluate`** scores the design on 8 categories (spacing, color, typography, structure, consistency, cliche, coverage, usability) and surfaces actionable issues with `nodeId` references — see "Design every state" below for what `coverage` demands. The `usability` category holds the mechanical UX floors beyond contrast: a control below the 24px hit-target floor is an **error** (WCAG 2.5.8 — a lone control inherits its padded row's box, so real settings rows pass), an unlabeled control is a warning, and vague action copy ("Click here") is an info. Controls also render with accent-derived `:focus-visible` rings in the live viewer — screenshots stay byte-identical because nothing is focused in a static capture. Use it in a generator-evaluator loop: `batch_design` → `canvas_evaluate` → fix the returned nodeIds. -- **`canvas_autofix`** runs `canvas_evaluate` internally and returns just the subset of issues that have a mechanically derived fix — off-scale spacing snaps to scale (`gap`, scalar padding, and array padding via a whole-array snap), missing layout becomes `vertical`, recoverable WCAG contrast failures get `#000` or `#FFF` based on background luminance. Pass `apply: true` to write every fix in the same call (the result reports applied/failed per op), or run the returned ops via `batch_design` yourself — either way, re-evaluate after. Closes the loop without judgment calls on your part. +- **`canvas_evaluate`** scores the design on 8 categories (spacing, color, typography, structure, consistency, cliche, coverage, usability) and surfaces actionable issues with `nodeId` references — see "Design every state" below for what `coverage` demands. The `usability` category holds the mechanical UX floors beyond contrast: a control below the 24px hit-target floor is an **error** (WCAG 2.5.8 — a lone control inherits its padded row's box, so real settings rows pass), an unlabeled control is a warning, and vague action copy ("Click here") is an info. The `structure` category also warns — directive-blocking — on unknown icon names (an empty slot in the render). Controls also render with accent-derived `:focus-visible` rings in the live viewer — screenshots stay byte-identical because nothing is focused in a static capture. Use it in a generator-evaluator loop: `batch_design` → `canvas_evaluate` → fix the returned nodeIds. +- **`canvas_autofix`** runs `canvas_evaluate` internally and returns just the subset of issues that have a mechanically derived fix — off-scale spacing snaps to scale (`gap`, scalar padding, and array padding via a whole-array snap), missing layout becomes `vertical`, recoverable WCAG contrast failures get `#000` or `#FFF` based on background luminance, and an unknown icon name with exactly one resolving spelling is rewritten to it. Pass `apply: true` to write every fix in the same call (the result reports applied/failed per op), or run the returned ops via `batch_design` yourself — either way, re-evaluate after. Closes the loop without judgment calls on your part. - **`canvas_evaluate` with `mode: "llm"`** runs fast-mode heuristics plus a vision-model critique against a **fixed rubric** (Claude or GPT-4.1, picked from env). Returns the heuristic result with an extra `llmCritique` field: five axes — **hierarchy, execution, specificity, restraint, variety** — each scored 1–5 with a rationale, plus a derived overall, `summary`, `suggestions`, and `needsRevision` / `failingAxes` (any axis below the `floor`, default 3). The verdict is stamped on the canvas + build log so quality is auditable over time. Use this for the "is this visually well-designed?" question heuristics can't answer — composition, hierarchy, polish. Costs one API call per run; reach for it after the heuristic score plateaus. - **`canvas_revise`** closes the loop: it judges, and for any failing axis asks the model for targeted `batch_design` ops, applies them, and re-judges — up to `maxIterations` passes (1–3). It **mutates the canvas**, reverts any pass that doesn't improve the overall, and stops on pass / cap / no-improvement. Opt-in and costly (≥2 API calls per pass); reach for it when you want the model to act on its own critique instead of you hand-translating it. - **`screenshot_responsive`** renders the same scene at mobile / tablet / desktop. Inspect all three; if `responsive` hints are set correctly the mobile layout will look right with no extra work. @@ -299,7 +300,7 @@ The bar is "designers say *wow*," not "competent." The cliché tells below are t Polishing the design to the bar is **your** job, not the user's — they should never have to point out a missing icon, an off-scale gap, or a low score. So the loop is part of designing, not an afterthought: 1. `canvas_evaluate` the canvas. -2. **Resolve every warning and every cliché tell** — `canvas_autofix` for the mechanical subset (spacing/contrast/known-default accent), `batch_design` for the rest. (Cliché tells are info/warning but they're the slop signal — always fix them.) Pure advisories like "consider extracting components" are optional refinements that don't block. +2. **Resolve every warning and every cliché tell** — `canvas_autofix` for the mechanical subset (spacing/contrast/known-default accent/misspelled icon), `batch_design` for the rest. (Cliché tells are info/warning but they're the slop signal — always fix them.) Pure advisories like "consider extracting components" are optional refinements that don't block. 3. Re-run `canvas_evaluate`. 4. Repeat until there are **zero errors, zero warnings, and zero cliché tells**. @@ -327,7 +328,7 @@ Polishing the design to the bar is **your** job, not the user's — they should | **Accent consistency** | 3+ competing saturated accent hues (excludes neutrals + the page background) | Pick **one** accent hue — plus neutrals and at most one status color. Set it once as `$accent`. | - **`cliche` is advisory** — tells are `warning`/`info`, never a hard error; they dent the score, they don't block. -- **`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. 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). +- **`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.) diff --git a/src/evaluate.ts b/src/evaluate.ts index ac1f23a..37973ac 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -4,6 +4,7 @@ import { getCanvasTokens } from './workspaces.js'; 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'; // --- Types --- @@ -720,6 +721,13 @@ function checkStructure(entries: NodeEntry[], canvas: Canvas): CheckResult { } } + // Unknown icons — an icon name that resolves in neither set renders an + // empty slot (the renderer emits only an HTML comment), invisible in + // screenshots. Warning severity: a blank icon is a shipped defect. + const iconFindings = checkUnknownIcons(canvas); + score -= 10 * iconFindings.length; + issues.push(...iconFindings); + // Component reuse if (totalNodes > 10 && instanceNodes === 0) { issues.push({ @@ -734,6 +742,89 @@ function checkStructure(entries: NodeEntry[], canvas: Canvas): CheckResult { return { score: Math.max(0, Math.min(100, Math.round(score))), issues }; } +/** Spellings worth trying for an icon name that didn't resolve: Material + * Symbols names multi-word icons with `_` (chevron_right), Lucide with `-` + * (chevron-right), and Material needs the `material:` prefix. Each candidate + * is checked against the bundled sets — only names that actually render. */ +function iconCandidates(ref: string, style: SceneNode['iconStyle']): string[] { + const { set, name } = parseIconRef(ref); + const lower = name.toLowerCase(); + const out = new Set(); + if (set === 'lucide') { + const hyphenated = lower.replace(/_/g, '-'); + if (hyphenated !== name && getIconSvg(hyphenated)) out.add(hyphenated); + if (getIconSvg(`material:${lower}`, 24, undefined, style)) out.add(`material:${lower}`); + } else { + // Keep the -fill suffix; the base name is what takes underscores. + const [, base, fill = ''] = lower.match(/^(.*?)(-fill)?$/)!; + const underscored = `${base.replace(/-/g, '_')}${fill}`; + if (underscored !== name && getIconSvg(`material:${underscored}`, 24, undefined, style)) out.add(`material:${underscored}`); + } + return [...out]; +} + +function checkUnknownIcons(canvas: Canvas): EvaluationIssue[] { + // Instance-expanded so icons inside stamped components are checked too; + // ids that aren't in the canvas tree itself live in a component definition. + const expanded = expandInstances(canvas.root, canvas); + const ownIds = new Set(); + (function walk(n: SceneNode) { ownIds.add(n.id); n.children?.forEach(walk); })(canvas.root); + + const icons: SceneNode[] = []; + (function walk(n: SceneNode) { if (n.type === 'icon') icons.push(n); n.children?.forEach(walk); })(expanded); + + // Tie-break between candidates by the icon set the rest of the canvas uses: + // mixing stroke (Lucide) and fill (Material) glyphs is its own inconsistency. + const resolvedSets = new Set(icons + .filter((n) => n.icon && getIconSvg(n.icon, 24, undefined, n.iconStyle)) + .map((n) => parseIconRef(n.icon!).set)); + const canvasSet = resolvedSets.size === 1 ? [...resolvedSets][0] : null; + + const issues: EvaluationIssue[] = []; + const seen = new Set(); + for (const node of icons) { + if (seen.has(node.id)) continue; + seen.add(node.id); + const ref = (node.icon ?? '').trim(); + if (ref && getIconSvg(ref, 24, undefined, node.iconStyle)) continue; + + const where = ownIds.has(node.id) ? '' : ' (inside a component — fix it in the component definition)'; + if (!ref) { + issues.push({ + category: 'structure', + severity: 'warning', + nodeId: node.id, + nodeName: node.name, + message: `Icon node has no icon name${where} — it renders as an empty slot.`, + suggestion: 'Set icon to a Lucide name ("chevron-right") or a Material Symbols name with the material: prefix ("material:chevron_right"), or delete the node.', + }); + continue; + } + + const candidates = iconCandidates(ref, node.iconStyle); + const preferred = canvasSet ? candidates.filter((c) => parseIconRef(c).set === canvasSet) : []; + const pick = candidates.length === 1 ? candidates[0] : preferred.length === 1 ? preferred[0] : null; + const issue: EvaluationIssue = { + category: 'structure', + severity: 'warning', + nodeId: node.id, + nodeName: node.name, + message: `Unknown icon "${ref}"${where} — it matches no bundled icon and renders as an empty slot.`, + suggestion: candidates.length > 0 + ? `Did you mean ${candidates.map((c) => `"${c}"`).join(' or ')}? Lucide names use hyphens (chevron-right); Material Symbols names use underscores and need the material: prefix (material:chevron_right).` + : 'Lucide names use hyphens (chevron-right); Material Symbols names use underscores and need the material: prefix (material:chevron_right). Check the spelling against lucide.dev or fonts.google.com/icons.', + }; + if (pick && ownIds.has(node.id)) { + issue.fix = { + op: formatUpdateOp(node.id, { icon: pick }), + rationale: `"${ref}" doesn't exist; "${pick}" is the spelling that resolves${candidates.length > 1 ? ' in the icon set this canvas already uses' : ''}.`, + }; + } + issues.push(issue); + } + return issues; +} + /** Phase 25 slice E — raw entries + merged tokens power the token-detachment * lint and the motion nudge. Both are SCORE-NEUTRAL info advisories. */ interface ConsistencyContext { diff --git a/src/icons.ts b/src/icons.ts index 21a83a0..fc33a8c 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -94,7 +94,10 @@ function getMaterialRoot(): string | null { } function materialSvg(name: string, size: number, color?: string, style: MaterialStyle = 'outlined'): string | null { - if (!name || !/^[a-z0-9-]+$/i.test(name)) return null; // names are file paths — reject anything path-like + // Names are file paths — allow only what Material Symbols file names use + // (letters, digits, `_` for multi-word names like chevron_right, `-` for the + // -fill suffix) and reject anything path-like (`.`, `/`). + if (!name || !/^[a-z0-9_-]+$/i.test(name)) return null; const resolvedStyle = MATERIAL_STYLES.includes(style) ? style : 'outlined'; const key = `${resolvedStyle}/${name}`; diff --git a/src/index.ts b/src/index.ts index f72daf1..88669d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,7 +63,7 @@ FINISH THE MODULE AS A SET. Products are sets of screens, and two things only ex Use the whole toolkit by default — a real UI uses these, so a good design must too (not only when asked): icons, fonts, controls, components, $tokens. Don't FAKE them (no Unicode-glyph icons, no ellipse "toggles") and don't OMIT them where a real UI has them — nav rows get a leading icon, metrics get an icon, feature lists get check icons, empty states get a glyph, forms use real controls. Starting from a pattern gives you all of this for free. -Icons & typography: two bundled icon sets render by name via the icon node type — Lucide ({ type: "icon", icon: "search" }) and Material Symbols (icon: "material:check", optional iconStyle outlined/rounded/sharp, "-fill" suffix for filled variants) — never fake icons with Unicode glyphs. Text nodes support letterSpacing / textTransform / fontVariationSettings — use textTransform: "uppercase" instead of baking casing into content. Input controls (toggle / checkbox / radio / select) are real node types ({ type: "toggle", checked: true }) styled from design tokens — never fake a control from frames + ellipses. +Icons & typography: two bundled icon sets render by name via the icon node type — Lucide ({ type: "icon", icon: "search" }) and Material Symbols (icon: "material:check", optional iconStyle outlined/rounded/sharp, "-fill" suffix for filled variants; multi-word names keep Material's underscores, e.g. "material:chevron_right", where Lucide uses hyphens, "chevron-right") — a name that resolves in neither set renders an empty slot and canvas_evaluate flags it as a structure warning; never fake icons with Unicode glyphs. Text nodes support letterSpacing / textTransform / fontVariationSettings — use textTransform: "uppercase" instead of baking casing into content. Input controls (toggle / checkbox / radio / select) are real node types ({ type: "toggle", checked: true }) styled from design tokens — never fake a control from frames + ellipses. Fonts load by name: set fontFamily in a typography token (or on a node) and the renderer resolves it from Google Fonts automatically (cached in ~/.framesmith/fonts/ — offline after first use). typography.body.fontFamily becomes the document default. Generic shorthands "mono" / "sans" render as CSS monospace / sans-serif — no registration, no network. batch_design warns immediately when a call writes a fontFamily nothing can serve yet (not cached / not registered / not generic) — heed it, and heed "Font warnings" in screenshot results: a warned family is rendering in the fallback stack, not the face you named. set_fonts is only needed for non-Google sources; with a css2 stylesheet URL, the family label you pass is the family that gets registered. @@ -116,7 +116,7 @@ const WORKFLOW_CHEATSHEET = [ const GOTCHAS = [ 'The bar: craft beautiful UI/UX a designer would sign off on, and polish to it YOURSELF — start from a pattern, use the whole toolkit (icons/fonts/controls/components), and run canvas_evaluate → resolve EVERY comment → re-evaluate until CLEAN (zero blocking issues) BEFORE presenting. Readiness turns on blocking findings, not the score — the score is always reported and worth climbing toward (aim for > 95), but a clean, sub-bar design is still READY. The evaluate result\'s "directive" field says when it\'s safe to present. Never show the user an unpolished design.', - 'Icons: Lucide ({ type: "icon", icon: "search" }) and Material Symbols (icon: "material:check", iconStyle outlined/rounded/sharp, "-fill" suffix for filled) render by name — never fake them with Unicode glyphs. Casing: use textTransform: "uppercase", not uppercased content.', + 'Icons: Lucide ({ type: "icon", icon: "search" }) and Material Symbols (icon: "material:check", iconStyle outlined/rounded/sharp, "-fill" suffix for filled) render by name — never fake them with Unicode glyphs. Spelling differs by set: Lucide hyphenates ("chevron-right"), Material keeps underscores AND needs the prefix ("material:chevron_right") — a bare "chevron_right" is looked up in Lucide and renders nothing; canvas_evaluate warns on any icon that resolves in neither set. Casing: use textTransform: "uppercase", not uppercased content.', 'Controls: toggle / checkbox / radio / select are real node types with checked / disabled / value, token-styled — never assemble them from frames + ellipses.', 'Bulk edits & queries: replace_matching_properties changes a property across every node matching a value predicate in one call (scope/type filters; dryRun previews the match set); find_nodes is the read-only twin — locate nodes by property/text/name and get ids + paths instead of guessing from read_nodes trees. canvas_autofix apply: true writes the whole mechanical fix set in one call.', 'Component scaffolds: apply_structure with kind "component" structures (data-table, form-field, toolbar, stat-card, toggle-row — plus the Phase 28 dashboard micro-patterns: kpi-card, status-chip, segmented-control, breadcrumb, initials-avatar, all on the tint pair) + targetId stamps reusable fragments with re-keyed IDs — build tables/forms from these, not node-by-node.', @@ -546,7 +546,8 @@ Grid: layout: "grid" + gridColumns (a count → equal columns; an array of fr we Icons: two bundled sets render by name — use these instead of Unicode glyph stand-ins (✓ ● ▾): - Lucide (1,900+, stroke style): I("parent", { type: "icon", icon: "search", iconSize: 24, iconColor: "$primary" }) — browse at lucide.dev - - Material Symbols (3,800+, fill style): icon: "material:check" + optional iconStyle: "outlined"|"rounded"|"sharp" (default outlined); "-fill" suffix selects the filled variant (e.g. "material:star-fill") — browse at fonts.google.com/icons + - Material Symbols (3,800+, fill style): icon: "material:check" + optional iconStyle: "outlined"|"rounded"|"sharp" (default outlined); "-fill" suffix selects the filled variant (e.g. "material:star-fill"); multi-word names use Material's underscores ("material:chevron_right", "material:chevron_right-fill") — browse at fonts.google.com/icons + - Spelling differs by set: Lucide hyphenates (chevron-right), Material uses underscores + the material: prefix. A name that resolves in neither set renders an empty slot; canvas_evaluate reports it as a structure warning Components: I("parent", { type: "instance", componentId: "cmp-shell", overrides: { "": { content: "..." } } }) stamps a registered component (create_component promotes an existing subtree into one; overrides match def children by NAME). Instance-level props override the def root. @@ -1981,7 +1982,7 @@ Stamping a genre does NOT move the canvas's versionHash (the hash covers design // --- canvas_evaluate --- server.tool( 'canvas_evaluate', - `Auto-score a design canvas against quality criteria. Returns an overall score (0-100), category scores, and actionable issues referencing specific node IDs. Categories: spacing, color, typography, structure, consistency (craft), plus "cliche" — the machine-made tells: default purple/indigo accents, gradient/glow overuse, fake browser/phone chrome (traffic-light dots), the hanging eyebrow-beside-heading header, fabricated-looking metrics/testimonials/logos, too many eyebrow labels (template rhythm — an eyebrow above nearly every section), slop copy (stock AI phrasing: filler verbs, scroll cues, placeholder names like "Jane Doe", hype labels), mixed radius systems (radius consistency — too many distinct corner radii), pure black/white (harsh #000000 ink or a stark #ffffff page vs a designed off-black/off-white), and competing accents (accent consistency — more than one accent hue). cliche issues carry a "tell" discriminator and are advisory (warning/info). The tells know dashboard vocabulary: a violet reached only through a $chart-* or $*-tint token doesn't trip the default-purple tell (a literal violet, or one through any OTHER token, still does), a gradient reached through a $token doesn't count toward gradient-overuse, thin rounded sparkline bars no longer match fake-chrome's small-circle census (it now requires a near-square shape: both dimensions ≤20px, aspect ratio ≤1.5:1, and round), and an uppercase label sitting beside a large tabular figure reads as a KPI-card metric — not counted in the eyebrow-rhythm census. The color category is DUAL-THEME aware: when the design system has a dark token layer (dark.colors — see set_variables / generate_color_system), contrast is checked in BOTH themes — dark-run issues carry theme: "dark", the category score takes the worse theme, and dark failures point at the dark token layer instead of carrying a literal fix (a node-level literal would break the light theme). WCAG 2.2 remains the gate; WCAG-passing pairs that are perceptually weak by APCA (Lc below ~75 body / ~60 large) get an INFO advisory — APCA is a candidate method, not a standard, and never blocks. Plus "coverage" (Phase 24): a BASE canvas whose content carries data — a detected table (empty + loading demanded) or a form of 3+ input controls (error demanded) — gets a directive-blocking WARNING per missing state variant (table detection needs THREE matching horizontal rows to read as data — two stacked rows are a layout, not a table — unless the frame is import-named "Table", which keeps the looser two-row shape); the result's "coverage" field reports { dataBearing, states, missing }. Designing the state is one canvas_add_variant + one scaffold stamp (empty-state / skeleton-table / skeleton-card / skeleton-stat-card); variant canvases themselves and non-data screens get no coverage findings. On a "loading" variant, coverage also flags a HALF-DESIGNED loading state — a data surface (a detected table, or a frame holding 2+ large tabular/numeric texts) still showing live values while a sibling region is skeletoned, one warning per offending region naming it and pointing at the matching skeleton scaffold. Plus "usability" (Phase 27): the mechanical UX floors beyond contrast — hit-target size gates as ERROR below the 24px WCAG 2.5.8 floor (the effective target counts a lone control's padded row, so a well-padded settings row passes; two controls sharing a row are measured bare; phone-width artboards additionally get a 44px touch-first info), a control with no text label in its row or group is a WARNING (selects carry their own value text and are exempt), and vague action copy ("Click here" / "Learn more" / "Read more" / "Submit" as the exact label) is an INFO — name the action instead. Modes: + `Auto-score a design canvas against quality criteria. Returns an overall score (0-100), category scores, and actionable issues referencing specific node IDs. Categories: spacing, color, typography, structure, consistency (craft), plus "cliche" — the machine-made tells: default purple/indigo accents, gradient/glow overuse, fake browser/phone chrome (traffic-light dots), the hanging eyebrow-beside-heading header, fabricated-looking metrics/testimonials/logos, too many eyebrow labels (template rhythm — an eyebrow above nearly every section), slop copy (stock AI phrasing: filler verbs, scroll cues, placeholder names like "Jane Doe", hype labels), mixed radius systems (radius consistency — too many distinct corner radii), pure black/white (harsh #000000 ink or a stark #ffffff page vs a designed off-black/off-white), and competing accents (accent consistency — more than one accent hue). cliche issues carry a "tell" discriminator and are advisory (warning/info). The tells know dashboard vocabulary: a violet reached only through a $chart-* or $*-tint token doesn't trip the default-purple tell (a literal violet, or one through any OTHER token, still does), a gradient reached through a $token doesn't count toward gradient-overuse, thin rounded sparkline bars no longer match fake-chrome's small-circle census (it now requires a near-square shape: both dimensions ≤20px, aspect ratio ≤1.5:1, and round), and an uppercase label sitting beside a large tabular figure reads as a KPI-card metric — not counted in the eyebrow-rhythm census. The color category is DUAL-THEME aware: when the design system has a dark token layer (dark.colors — see set_variables / generate_color_system), contrast is checked in BOTH themes — dark-run issues carry theme: "dark", the category score takes the worse theme, and dark failures point at the dark token layer instead of carrying a literal fix (a node-level literal would break the light theme). WCAG 2.2 remains the gate; WCAG-passing pairs that are perceptually weak by APCA (Lc below ~75 body / ~60 large) get an INFO advisory — APCA is a candidate method, not a standard, and never blocks. Plus "coverage" (Phase 24): a BASE canvas whose content carries data — a detected table (empty + loading demanded) or a form of 3+ input controls (error demanded) — gets a directive-blocking WARNING per missing state variant (table detection needs THREE matching horizontal rows to read as data — two stacked rows are a layout, not a table — unless the frame is import-named "Table", which keeps the looser two-row shape); the result's "coverage" field reports { dataBearing, states, missing }. Designing the state is one canvas_add_variant + one scaffold stamp (empty-state / skeleton-table / skeleton-card / skeleton-stat-card); variant canvases themselves and non-data screens get no coverage findings. On a "loading" variant, coverage also flags a HALF-DESIGNED loading state — a data surface (a detected table, or a frame holding 2+ large tabular/numeric texts) still showing live values while a sibling region is skeletoned, one warning per offending region naming it and pointing at the matching skeleton scaffold. Plus "usability" (Phase 27): the mechanical UX floors beyond contrast — hit-target size gates as ERROR below the 24px WCAG 2.5.8 floor (the effective target counts a lone control's padded row, so a well-padded settings row passes; two controls sharing a row are measured bare; phone-width artboards additionally get a 44px touch-first info), a control with no text label in its row or group is a WARNING (selects carry their own value text and are exempt), and vague action copy ("Click here" / "Learn more" / "Read more" / "Submit" as the exact label) is an INFO — name the action instead. The structure category also WARNS (directive-blocking, -10 structure score each) on every icon node whose name resolves in neither bundled set — it would render as an empty slot, invisible in a screenshot: the suggestion names the spellings that do resolve (hyphen/underscore swap, or the missing material: prefix), and when exactly one does — or several do but only one matches the icon set the rest of the canvas uses — the issue carries a fix canvas_autofix applies. Icons inside component instances are checked too; they point at the component definition and carry no fix op. Modes: - "fast": JSON-only, <100ms, deterministic heuristics only. - "detailed": adds Puppeteer-based pixel overlap detection in the consistency category. - "llm": fast-mode heuristics plus a vision-model critique against a FIXED rubric (provider picked from FRAMESMITH_LLM_PROVIDER env var, or whichever of ANTHROPIC_API_KEY / OPENAI_API_KEY is set). Adds an "llmCritique" field: { rubric: { hierarchy, execution, specificity, restraint, variety } each {score 1-5, rationale}, score (0-100 derived), summary, suggestions, needsRevision, failingAxes }. The verdict is stamped on the canvas (metadata.critique) + the per-project build log for auditability. Cost: one paid API call per invocation. To CLOSE the loop and auto-fix failing axes, use canvas_revise. @@ -2050,7 +2051,7 @@ The result's "genre" field (present whenever cliche ran) makes the genre decisio // --- canvas_autofix --- server.tool( 'canvas_autofix', - `Run canvas_evaluate in fast mode and return the subset of issues that have a mechanically derived fix (off-scale spacing — gap, scalar AND array-form padding (the fix writes the complete snapped array) — → snap to scale; missing layout on multi-child frame → set vertical; recoverable WCAG contrast failure → switch text to #000 or #FFF, whichever wins; default-purple accent → swap to a neutral accent; fake-chrome strip → delete; pure-black ink → soften to off-black; a literal fill/stroke/color/cornerRadius that exactly equals a SINGLE token's value → re-pointed to the \$ref — a value shared by multiple tokens is reported with candidates but never auto-fixed). By default this PROPOSES: each fix carries a ready-to-paste \`batch_design\` op string and a one-line rationale. Pass apply: true to also WRITE the fixes to the canvas in the same call — the result then reports applied/failed per op. Taste-dependent cliche tells (gradient/glow overuse, the hanging header, fabricated content, eyebrow-rhythm overuse, slop copy, mixed radius systems, competing accents) carry a suggestion but no auto-fix — call canvas_evaluate to see those. Coverage warnings (missing empty/loading/error state variants) also have no mechanical fix: designing a state is judgment — the suggestion names the canvas_add_variant + scaffold path. Usability findings (hit-target size, label association, vague action copy) likewise have no mechanical fix — growing a control, writing a label, or renaming a button is a judgment call; call canvas_evaluate and fix them via batch_design. Closes the generator-evaluator loop: generate with batch_design → autofix (apply: true) → re-evaluate.`, + `Run canvas_evaluate in fast mode and return the subset of issues that have a mechanically derived fix (off-scale spacing — gap, scalar AND array-form padding (the fix writes the complete snapped array) — → snap to scale; missing layout on multi-child frame → set vertical; recoverable WCAG contrast failure → switch text to #000 or #FFF, whichever wins; default-purple accent → swap to a neutral accent; fake-chrome strip → delete; pure-black ink → soften to off-black; a literal fill/stroke/color/cornerRadius that exactly equals a SINGLE token's value → re-pointed to the \$ref — a value shared by multiple tokens is reported with candidates but never auto-fixed). By default this PROPOSES: each fix carries a ready-to-paste \`batch_design\` op string and a one-line rationale. Also: an unknown icon name with exactly one spelling that resolves (hyphens ↔ underscores, or a missing material: prefix — ties broken by the icon set the canvas already uses) → rewritten to that name. Pass apply: true to also WRITE the fixes to the canvas in the same call — the result then reports applied/failed per op. Taste-dependent cliche tells (gradient/glow overuse, the hanging header, fabricated content, eyebrow-rhythm overuse, slop copy, mixed radius systems, competing accents) carry a suggestion but no auto-fix — call canvas_evaluate to see those. Coverage warnings (missing empty/loading/error state variants) also have no mechanical fix: designing a state is judgment — the suggestion names the canvas_add_variant + scaffold path. Usability findings (hit-target size, label association, vague action copy) likewise have no mechanical fix — growing a control, writing a label, or renaming a button is a judgment call; call canvas_evaluate and fix them via batch_design. Closes the generator-evaluator loop: generate with batch_design → autofix (apply: true) → re-evaluate.`, { canvasId: z.string().describe('Canvas ID to autofix'), categories: z.array(z.enum(['spacing', 'color', 'typography', 'structure', 'consistency', 'cliche', 'coverage', 'usability'])) diff --git a/test-material-icons.ts b/test-material-icons.ts index 0547ee7..b782dca 100644 --- a/test-material-icons.ts +++ b/test-material-icons.ts @@ -51,7 +51,16 @@ const expect = (name: string, ok: boolean, detail?: string) => checks.push({ nam expect('styles differ (distinct glyph data)', rounded !== outlined || sharp !== outlined); expect('material unknown name → null', getIconSvg('material:not-a-real-symbol') === null); + // Issue #214 — multi-word Material Symbols names use underscores. + expect('underscored name resolves', getIconSvg('material:chevron_right') !== null); + expect('underscored -fill variant resolves', getIconSvg('material:chevron_right-fill') !== null); + expect('underscored -fill is the filled glyph', (() => { + const filled = getIconSvg('material:account_circle-fill'); + return filled !== null && filled !== getIconSvg('material:account_circle'); + })()); expect('path traversal rejected', getIconSvg('material:../../package') === null); + expect('dot-relative path rejected', getIconSvg('material:../x') === null); + expect('slash path rejected', getIconSvg('material:a/b') === null); expect('unsafe color dropped, icon still renders', (() => { const s = getIconSvg('material:check', 24, '">