From 489962a830965e5044151d904579934954f3427c Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sun, 8 Nov 2026 20:32:39 +0100 Subject: [PATCH] Composite translucent colors in the contrast check The check took the nearest fill as solid paint and dropped its alpha, so an 8% amber tint on a near-black panel was judged as solid amber (1.72:1, a false error; the real pair is 13.45:1), and rgba(0,0,0,0) read as black. Translucent fills now blend over the fills behind them (or the white page), translucent text blends into that, and "transparent" is see-through. --- docs/GUIDELINES.md | 2 +- src/evaluate.ts | 71 ++++++++++++++++++++++++++------------- src/index.ts | 2 +- test-contrast-alpha.ts | 75 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 test-contrast-alpha.ts diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 761ad76..b53db00 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -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."` — 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. diff --git a/src/evaluate.ts b/src/evaluate.ts index f72f011..8f11d8e 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -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[] { @@ -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 diff --git a/src/index.ts b/src/index.ts index 8198c20..3c35fe4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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. diff --git a/test-contrast-alpha.ts b/test-contrast-alpha.ts new file mode 100644 index 0000000..1c6efc1 --- /dev/null +++ b/test-contrast-alpha.ts @@ -0,0 +1,75 @@ +// canvas_evaluate's contrast check composites translucent colors. +// A translucent fill (rgba / #RRGGBBAA) is blended over the fills behind it, +// and a translucent text color over the resulting background, before the WCAG +// ratio is computed. Before this, the alpha was dropped: a selected-state tint +// of rgba(245,158,11,0.08) on a near-black panel was judged as solid amber +// (1.72:1, a false error; the real pair is 13.45:1). +// +// Usage: npx tsx test-contrast-alpha.ts + +import './test-env.js'; +import { evaluateCanvas } from './src/evaluate.js'; +import type { Canvas, SceneNode } from './src/types.js'; + +let allPass = true; +function check(name: string, cond: boolean, extra?: string) { + if (!cond) allPass = false; + console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ` — ${extra}` : ''}`); +} + +function canvasOf(rootFill: string | undefined, layerFill: string | undefined, textColor: string): Canvas { + const text: SceneNode = { id: 't1', name: 'label', type: 'text', content: 'Sample copy', color: textColor, fontSize: 16 }; + const layer: SceneNode = { id: 'layer', name: 'layer', type: 'frame', width: 200, height: 40, children: [text] }; + if (layerFill) layer.fill = layerFill; + const root: SceneNode = { id: 'root', name: 'root', type: 'frame', width: 400, height: 200, children: [layer] }; + if (rootFill) root.fill = rootFill; + return { id: 'c1', name: 'c', projectId: 'p', root, components: {}, variables: {}, createdAt: '', lastModified: '' } as Canvas; +} + +async function contrastErrors(canvas: Canvas) { + const result = await evaluateCanvas(canvas, { mode: 'fast', categories: ['color'] }); + return result.issues.filter((i) => i.category === 'color' && i.severity === 'error' && i.nodeId === 't1'); +} + +// 1. The viewer case: an 8% amber tint on a near-black panel. +let errs = await contrastErrors(canvasOf('#14110C', 'rgba(245, 158, 11, 0.08)', '#FDE68A')); +check('8% tint on a dark panel is not a false error', errs.length === 0, errs[0]?.message); + +// 2. Same tint written as #RRGGBBAA. +errs = await contrastErrors(canvasOf('#14110C', '#F59E0B14', '#FDE68A')); +check('#RRGGBBAA tint composites too', errs.length === 0, errs[0]?.message); + +// 3. A faint white wash over black still reads dark: dark text on it fails. +errs = await contrastErrors(canvasOf('#000000', 'rgba(255, 255, 255, 0.1)', '#222222')); +check('a 10% white wash over black is judged dark, so dark text fails', errs.length === 1); + +// 4. A fully transparent fill is no background at all. +errs = await contrastErrors(canvasOf('#FFFFFF', 'rgba(0, 0, 0, 0)', '#333333')); +check('a fully transparent fill is skipped', errs.length === 0, errs[0]?.message); + +// 5. Translucent text blends into its background. +errs = await contrastErrors(canvasOf('#FFFFFF', undefined, 'rgba(0, 0, 0, 0.3)')); +check('30% black text on white fails', errs.length === 1); + +// 6. Translucent fills with no opaque fill behind them sit on the white page. +errs = await contrastErrors(canvasOf(undefined, 'rgba(0, 0, 0, 0.05)', '#FFFFFF')); +check('a 5% tint with nothing behind it is judged over the white page', errs.length === 1); + +// 7. The message names the color it actually measured against. +errs = await contrastErrors(canvasOf('#000000', 'rgba(255, 255, 255, 0.1)', '#222222')); +check('the message shows the composited background', /composited/.test(errs[0]?.message ?? ''), errs[0]?.message); + +// 8. The `transparent` keyword is see-through too. +errs = await contrastErrors(canvasOf('#FFFFFF', 'transparent', '#333333')); +check('a "transparent" fill is skipped', errs.length === 0, errs[0]?.message); + +// 9. A fill the parser cannot read still paints: no check, as before. +errs = await contrastErrors(canvasOf('#FFFFFF', 'hsl(0, 0%, 10%)', '#333333')); +check('an unreadable fill stops the walk instead of measuring past it', errs.length === 0, errs[0]?.message); + +// 10. Opaque pairs are unchanged. +errs = await contrastErrors(canvasOf('#FFFFFF', undefined, '#777777')); +check('opaque #777 on white still fails as before', errs.length === 1 && /4\.48:1/.test(errs[0].message), errs[0]?.message); + +console.log(allPass ? '\nALL PASS' : '\nSOME FAILED'); +process.exit(allPass ? 0 : 1);