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
2 changes: 1 addition & 1 deletion docs/GUIDELINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ Authoring rules:
- **Start the look with one call — `generate_design_system`.** `generate_design_system({ seed: "#0E7490", personality: "technical", workspaceId })` writes the complete design language: the color system, a curated font pairing (real faces load from the first screenshot), typography **roles** (`$display` / `$heading` / `$title` / `$body` / `$label` / `$caption`, plus `$figures` with a mono face), a radius and density stance, `$elevation.flat|raised|floating|overlay` depth tokens (the dark layer re-states each depth so it reads on dark surfaces — reference as `shadow: "$elevation.raised"`), and `$motion` defaults. The personality is **required** and is a real design stance: `technical` (crisp product tool), `editorial` (serif voice, generous air), `soft` (rounded, warm), `data-dense` (instrument-panel density). Same seed, different personality = visibly different product. The two generators below regenerate just one part.
- **Don't hand-pick a type scale — generate one.** `generate_scale({ ratio: "major-third", workspaceId })` derives a full `text-xs`…`text-3xl` scale plus a paired `space-3xs`…`space-3xl` scale from a named ratio (`minor-second` 1.125, `major-second` 1.2, `minor-third` 1.25, `major-third` 1.333, `perfect-fourth` 1.5, `golden` 1.618 — or any number), with the craft defaults (line-height bands, display tracking) baked in and sizes pinned against the ratio check. Pass `fluid: {}` for Utopia-style `clamp()` sizes that interpolate between viewports instead of static px.
- **Don't eyeball a palette from ten hexes — generate one.** `generate_color_system({ seed: "#2563EB", workspaceId })` derives OKLCH `primary-50`…`900` + a matched neutral ramp + status colors + the `bg-surface`/`text-primary`/`accent`/… semantic tokens the structures already speak, with every text/surface pair AA-clean by construction. Status colors (`success`/`warning`/`danger`) are tuned as TEXT — each is darkened from its hue's band until it clears AA (4.5:1) against white, so a `$danger` validation message is legible, not just legible-adjacent. It also derives the color range — a `$chart-1`…`$chart-6` categorical series palette and the `-tint` layer for chips/tiles/badges — see "Designing with character" below.
- **It ships a dark theme too.** The dark mapping lands in a sparse `dark.colors` override layer alongside the light tokens (or set one by hand via `set_variables`, which also accepts `dark.elevation` for depth-token overrides) — status colors get a second, dark-specific pass here so they clear AA against the dark surface as well. Render it with `theme: "dark"` on `screenshot` / `screenshot_responsive` / `export`; `canvas_evaluate`'s `color` category then contrast-checks both themes automatically and scores the worse one — dark-run failures point at the dark token layer (a node literal would break light). WCAG 2.2 is the gate; **APCA** Lc values appear as info-only advisories on perceptually weak pairs (APCA is a candidate method, not a standard — it never blocks).
- **It ships a dark theme too.** The dark mapping lands in a sparse `dark.colors` override layer alongside the light tokens (or set one by hand via `set_variables`, which also accepts `dark.elevation` for depth-token overrides) — status colors get a second, dark-specific pass here so they clear AA against the dark surface as well. Render it with `theme: "dark"` on `screenshot` / `screenshot_responsive` / `export`; `canvas_evaluate`'s `color` category then contrast-checks both themes automatically and scores the worse one — dark-run failures point at the dark token layer (a node literal would break light). Contrast is measured as rendered: translucent fills and text (`rgba` / `#RRGGBBAA`) composite over what is behind them, so a tinted selected or hover row is judged by its real color. WCAG 2.2 is the gate; **APCA** Lc values appear as info-only advisories on perceptually weak pairs (APCA is a candidate method, not a standard — it never blocks).
- **Give motion the same treatment as color and type.** Declare `motion` tokens via `set_variables` (`{ fast: { duration: 150, easing: "ease-out" } }`) and reference them as `transition: "$motion.<name>"` — ad-hoc duration/easing pairs sprawling across a canvas with no declared motion tokens get an advisory.

Merge semantics are per-category: a project that only sets `colors` doesn't reset the workspace's `spacing`/`radius`/`typography`. A canvas that only overrides `colors.primary` keeps every other workspace color.
Expand Down
71 changes: 49 additions & 22 deletions src/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,12 @@ export function parseAlpha(str: string): number {
return 1;
}

/** Source-over blend of `top` at `alpha` onto an opaque `base`. */
function composite(top: [number, number, number], alpha: number, base: [number, number, number]): [number, number, number] {
if (alpha >= 1) return top;
return [0, 1, 2].map((i) => Math.round(alpha * top[i] + (1 - alpha) * base[i])) as [number, number, number];
}

// --- Tree walking ---

function buildTreeContext(root: SceneNode): NodeEntry[] {
Expand Down Expand Up @@ -414,37 +420,58 @@ function checkColorContrast(entries: NodeEntry[], theme?: 'dark'): CheckResult {

for (const { node, parent } of textNodes) {
const fgStr = node.color ?? '#000000';
const fg = parseColor(fgStr);
if (!fg) continue;

// Walk up to find nearest background fill
let bgStr: string | null = null;
const fgRaw = parseColor(fgStr);
if (!fgRaw) continue;

// Walk up collecting fills until an opaque one. Translucent fills
// (rgba / #RRGGBBAA) are layers the text sees THROUGH, so they composite
// over what is behind them instead of counting as solid paint.
const layers: string[] = [];
let onGradient = false;
let unreadable = false;
let current = parent;
const allEntries = entries;
while (current) {
if (current.gradient) {
issues.push({
category: 'color',
severity: 'info',
nodeId: node.id,
nodeName: node.name,
message: `Text "${(node.content ?? '').slice(0, 30)}" is on a gradient background. Cannot compute contrast automatically.`,
});
bgStr = null;
onGradient = true;
break;
}
if (current.fill && !current.fill.startsWith('$')) {
bgStr = current.fill;
break;
const see = current.fill === 'transparent' || current.fill === 'none';
if (current.fill && !see && !current.fill.startsWith('$')) {
// A fill we cannot parse (hsl(), a named color) still paints: stop
// here and skip the check rather than measure against what is behind.
if (!parseColor(current.fill)) {
unreadable = true;
break;
}
const alpha = parseAlpha(current.fill);
if (alpha > 0) layers.push(current.fill);
if (alpha >= 1) break;
}
// Find parent of current
const parentEntry = allEntries.find((e) => e.node.children?.includes(current!));
const parentEntry = entries.find((e) => e.node.children?.includes(current!));
current = parentEntry?.node ?? null;
}

if (!bgStr) continue;
const bg = parseColor(bgStr);
if (!bg) continue;
if (onGradient) {
issues.push({
category: 'color',
severity: 'info',
nodeId: node.id,
nodeName: node.name,
message: `Text "${(node.content ?? '').slice(0, 30)}" is on a gradient background. Cannot compute contrast automatically.`,
});
continue;
}
if (unreadable || layers.length === 0) continue;

// No opaque fill behind the layers: they sit on the page, which renders white.
const backmost = layers[layers.length - 1];
let bg: [number, number, number] = parseAlpha(backmost) >= 1 ? parseColor(backmost)! : [255, 255, 255];
for (let i = layers.length - 1; i >= 0; i--) {
if (parseAlpha(layers[i]) < 1) bg = composite(parseColor(layers[i])!, parseAlpha(layers[i]), bg);
}
const fg = composite(fgRaw, parseAlpha(fgStr), bg);
const composited = layers.length > 1 || parseAlpha(layers[0]) < 1;
const bgStr = composited ? `${layers[0]} (composited: ${rgbToHex(bg)})` : layers[0];

// Round to 2 decimals — the precision contrast is conventionally reported at
// (WebAIM et al.). Comparing the rounded value stops a ratio that rounds to
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2096,7 +2096,7 @@ server.tool(
// --- 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 warning/info severity — but EVERY tell blocks the directive regardless of severity. 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 WEB artboards additionally get a 44px touch-first info; on a canvas declared mobile, 44px is an ERROR instead — see the surface paragraph), 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, and every mobile primitive icon prop (button.icon, list-row / nav-bar leading.icon, nav-bar trailing[].icon, tab-bar items[].icon — required, so a tab with no icon warns too — and tab-bar fab.icon), 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 — an icon node or button.icon carries a fix canvas_autofix applies (the nested props name the spelling to write). Icons inside component instances are checked too; they point at the component definition and carry no fix op. 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 warning/info severity — but EVERY tell blocks the directive regardless of severity. 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). Contrast is measured as rendered: translucent fills (rgba / #RRGGBBAA) composite over the fills behind them (over the white page when no opaque fill is behind), translucent text blends into that, and "transparent" is see-through — so a tinted selected or hover fill is judged by its real color, and the message names the composited one. 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 WEB artboards additionally get a 44px touch-first info; on a canvas declared mobile, 44px is an ERROR instead — see the surface paragraph), 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, and every mobile primitive icon prop (button.icon, list-row / nav-bar leading.icon, nav-bar trailing[].icon, tab-bar items[].icon — required, so a tab with no icon warns too — and tab-bar fab.icon), 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 — an icon node or button.icon carries a fix canvas_autofix applies (the nested props name the spelling to write). 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, laid out at the canvas's artboard size (siblings that content scrolls under — absolute, sticky, pinned chrome, a sheet, a bar pinned to the bottom — are exempt; a bar pinned to the top over in-flow content still counts).
- "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.
Expand Down
Loading
Loading