Measured by replaying the extractors against cached crawls of the 27
benchmark sites, so only the code varied between runs.
Typography: syntax highlighting draws every token as its own text element, so
counting text-rendering elements made plexMono (tailwindcss, 450 vs Inter 131),
commitMono (resend) and Departure Mono (supabase) the body font. Families whose
name marks a code face are labelled "mono" and sort after the text faces.
Colours: usage, interactive backgrounds and area now count in proportion to
chroma (full weight from chroma 30), and area scores 60*sqrt(share).
Near-black #101214 (chroma 1.5) had won atlassian on 702 uses, #231e15 had
won mailchimp, and one element painting 84% of discord.com had won on area.
Replay, previous -> this: primary colour 10/17 -> 13/17, body font
24/27 -> 27/27, no site regressed. Still missed: linear (brand colour on one
element), slack, paypal, supabase (a lighter shade of the brand green). The three
new tests fail on the previous code.
Measured per cluster on real sites, the linear weights let a single button
decide: 100 points per interactive background against at most ~3.5 for usage
and ~2.5 for painted area. gov.uk's CTA green on 4 buttons outranked the
GOV.UK blue, Framer's one #0066ff button outranked Framer Blue used 44 times,
and HSL saturation scored near-black #002533 (l=10) as vivid as a real brand
colour.
- interactive backgrounds: 40 * log2(1 + n), strong with diminishing returns
- chroma instead of HSL saturation
- 25 * log10(count) and 300 * areaShare so repetition and surface count
- the browser's default link colours (#0000ee, #551a8b) are never the brand
Re-extracted: gov.uk, duolingo and framer now return the brand colour; hubspot
unchanged; supabase (lighter shade of the brand green), paypal and linear still
miss. Three of the five new tests fail on the previous ranking.
Head and metadata elements carry the browser default (Times) without drawing
anything. On gov.uk they were 35 of Times's 36 uses; on dropbox.com Times had
1257 elements but only 90 with text, against 201 for Atlas Grotesk, so
designlang reported Times as dropbox's body font.
- families count only text-rendering elements (records without hasText, from
older snapshots, still count)
- a family used on neither headings nor body copy is labelled "other", not body
- React/MUI theme, Tailwind and CSS vars give the body slot to the most used
body-capable family and never overwrite it (supabase's tailwind body had
become Source Code Pro)
process.exit() straight after process.stdout.write() drops whatever the pipe
hasn't flushed. `designlang https://gov.uk --json | jq` received exactly
65536 bytes of a 70583-byte document, so any site with a large design broke
JSON consumers in CI. Found by the benchmark runner, which reads through a pipe.
- writeThenExit(): exit once the write is handed to the OS; used by extraction
--json and drift --json (which exits non-zero on drift right after writing)
- the export command's error path uses the new exit codes
- bench: a family designlang marks usage "all" counts as body text, and both
readers take the most used body family
Several brands are monochrome or render body text in a system stack, so no
sourced brand colour or font appears on the live page. Scoring either tool
against an absent truth would be noise. Such a dimension now scores null and
is left out of that dimension's denominator; a failed run still counts as a
miss wherever truth exists.
- bench/score.js: unit-tested scoring. Primary colour counts within CIE76
dE 10 of the brand colour; body font by normalised family name. The same
reading rule for both tools: the family labelled body, else the most used
- bench/run.mjs: designlang (this checkout) vs pinned dembrandt 0.33.0,
default settings, sequential, alternating order per site, hard timeout,
writes bench/results/<date>.{json,md} with losses included
- bench/evidence.mjs: ground-truth evidence independent of either extractor
(the family that actually renders body copy; closest painted colour to each
claimed brand colour)
- keep bench/ out of the npm tarball
Every failure exited 1, so a CI gate could not tell a design regression from a
flaky preview deploy.
- exit 0 ok, 1 drift over threshold, 2 extraction failed, 3 navigation timeout;
wired into extraction, drift, ci and lint
- the Action installs the designlang version it was tagged with, runs
install-browser, fails on drift by default, annotates each changed token
(escaped), says why a job failed, and lists Manavarya09 as author
- fix changed-count being written as "0" twice when nothing changed
- README: install-browser, live MCP tools, CLI reference
- 13.3.0 changelog with the breaking changes called out
The MCP server could only read an extraction already on disk, so an agent
could not point it at a URL.
- extract_design returns a job id; get_job_status, list_jobs, cancel_job
- get_tokens, get_colors, get_typography, get_components by job id
- compute_drift (job vs baseline job), get_findings (token lint + WCAG),
export (dtcg, tailwind, shadcn, figma, css, design-md)
- existing folder-backed tools and resources are unchanged
- an initialize without protocolVersion is served on the default revision
instead of rejected (#182, #183)
postinstall downloaded Chromium on every npm install, which fails in CI, Docker
and behind proxies. Browsers are now installed on demand.
- designlang install-browser [--with-deps] installs the Chromium that matches
the bundled playwright
- src/browser.js launchChromium(): bundled Chromium, then system Chrome, then a
BROWSER_UNAVAILABLE error that names the fix; all 13 launch sites use it
- doctor, the extraction error and smithery.dockerfile point at install-browser
- release.yml publishes with provenance when a vX.Y.Z tag matches package.json
- MCP serverInfo reports the package version instead of a hard-coded 7.0.0
- add src/mcp/index.js, which the ./mcp export pointed at but never existed
- test a stock SDK client handshake over stdio
Running the new extractors against Stripe, Linear, Vercel and Tailwind
surfaced four ways the heuristics lied:
- **Measure read paragraph boxes, not text.** A <p> is as wide as its
container; its text usually is not, so Stripe reported 22 characters
per line. The crawler now measures real line boxes with a Range over
each prose element (budgeted at 120 elements) and the extractor takes
the median of the reading column only — the narrow <p> furniture in
navs and footers is excluded. Stripe now reports 53, Linear 47.
- **The content column was whatever repeated hardest** — a 400px card on
Stripe. It is now the constrained width governing the most page area,
with a genuine full-bleed shell reported as `fullBleed` plus the widest
constrained block inside it, rather than having some card promoted in
its place.
- **`display` went to the first untagged size**, which put a 32px step
above a 48px <h1>. It is now reserved for the largest step on the page;
other untagged sizes become ordered title rungs.
- **Fluid ranges serialized as null** via Infinity when no declaration
resolved to px (`min(var(--x), 2.75vw)`), and measure asserted a verdict
off a single sample. Both now carry their own caveat — `resolvedCount`
and a confidence level.
Extraction handed back one hex per role and left every consumer to invent
the rest of the palette by hand. Three additions:
- **Tonal ramps.** A 50–950 ladder per role plus the dominant neutral,
generated in OKLCH so the steps are perceptually even — HSL lightness
is not, which is why hand-built ramps drift. Chroma peaks mid-ladder
and falls off at both ends, and the site's own colour keeps its rung
(reported as `anchor`), so the ramp contains the real brand colour
rather than a re-derived approximation of it.
- **Semantic pairs.** surface.page, surface.raised and the action colours,
each paired with the foreground that actually reaches contrast on it,
with the ratio and WCAG level attached. A colour the site already uses
for text wins; black or white is a marked fallback.
- **Area-weighted dominance.** Colour usage counted elements, so two
hundred 16px icons outranked a hero background. Backgrounds now
accumulate painted area, exposed as `dominance` and folded into brand
ranking as a weak term.
Ramps and pairs are emitted through css-vars and the DTCG tokens, where
semantic action/surface tokens now carry a measured `onX` foreground.
Also stops `semantic.shadow.elevated` pointing at sh0 — the faintest
hairline on the page — in favour of a mid rung of the elevation ladder.
extractLayout counted grids and listed every max-width it saw. What a
clone actually needs is the system underneath: the content column width
and its gutters (read from rendered width, so percentage and clamp()
containers land in the ladder too), the column count the grids agree on
weighted by governed area rather than by instance count, the vertical
rhythm full-bleed sections use — the number that decides whether a clone
feels right or cramped, and one that never survives a spacing scale built
from every padding on the page — a numeric gap ladder, and the fluid
max-width/gap declarations.
Also fixes `gaps`, which was string-sorted: '10px' ranked before '4px'.
`typography.scale` was an inventory — every distinct px size found, in
descending order, with no way to tell a display size from body copy. Four
additions turn it into a system:
- **Modular ratio inference.** Fits the scale against the eight standard
ratios with a power penalty (without one, a minor second explains any
scale by raising itself to the 4th) and reports fit, ladder coverage and
the anchor size, or refuses to name a ratio at all.
- **Named scale.** Every step gets a usable role — heading tags claim
their own, the rest are placed relative to body size.
- **Fluid type.** Computed styles resolve clamp() to one px value at the
capture viewport, so a site's responsive ramp was invisible. The crawler
now harvests authored fluid declarations off the stylesheets (including
cross-origin sheets fetched as text) and the extractor parses them into
min/max px — flagging clamps whose preferred term has no viewport unit
and therefore never scale.
- **Measure.** Characters per line for body copy, with a narrow/
comfortable/wide verdict. Needed rendered box widths, so computed styles
now carry width, height and document-relative top.
Also fixes the DTCG `semantic.typography.body` token, which read from
scale[0] — the largest size — and so emitted the headline size as body
text. It now resolves the real body step, and primitive font sizes are
keyed by role rather than index.
Composite box-shadow values (`0 1px 2px …, 0 10px 15px -3px …` — what
Tailwind and most design systems emit) were parsed as a single shadow:
the colour regex grabbed the first layer's colour and the numeric regex
swept offsets across every layer, so blur, offset and spread were wrong
for the majority of real sites.
Layers are now split on top-level commas and parsed individually; the
composite reports from the key (widest) layer, so an ambient hairline
stacked under a key shadow no longer drags the reported elevation down.
On top of that the raw shadow set collapses into an ordered elevation
ladder — buckets within 25% visual weight merge, the most-used raw value
wins its bucket — plus per-shadow tint (neutral vs brand-coloured, with
hue and alpha) and a system summary carrying level count and redundancy.
The css-vars, DTCG tokens and Tailwind emitters now key off the ladder.
They previously keyed off `label`, which collides whenever a site has
more than one shadow in the same size band — those tokens were silently
overwriting each other.
Unit coverage aside, these are the acceptance criteria for the space itself:
* two dark minimal systems land nearer each other than either does to a
playful one — the space discriminates by design family
* extraction noise moves a vector by <0.02 while a real restyle moves it
an order of magnitude further — it measures design, not jitter
* features either side is missing are skipped, never invented
* a corpus built for another vector version is refused, not compared
Two of these caught real bugs against live sites: the extractors emit HSL
on a 0-100 scale while the readers assumed 0-1, and font weights and
line-heights arrive as CSS strings. Stripe went from 93% to 100% of features
measurable once both were fixed.
Prints a one-screen sanity check of the local install so a failing
extraction can be diagnosed in seconds: Node version against the
`engines` floor, designlang version, playwright version, whether the
resolved Chromium binary actually exists on disk, output-dir
writability, and network reachability.
Failing checks print a fix hint. Exits 0 when everything passes, 1 when
any check fails. The network probe uses a 3s timeout and reports WARN
rather than FAIL so an offline environment does not produce a
misleading exit code.
No new dependencies. Closes#107.
Two things:
1. clone --fidelity: after generating the Next.js starter, rebuild its
components from the extracted tokens, pixel-diff vs the live site, and write
FIDELITY.md + fidelity.json + fidelity-card.svg + a ranked correction plan
into the clone. Reuses the existing extraction (no second crawl) and points
the user at `designlang fidelity` for the full visual+motion score of the
running clone.
2. resolveOut(): the root command greedily owns -o/--out, and a sub-level
*default* masked any override (opts.out was always truthy). clone/verify/
visual-diff dropped their masking defaults and now resolve via resolveOut,
which prefers an explicit sub value, else the root value only when actually
passed on the CLI (getOptionValueSource === 'cli'), else the fallback.
Verified end-to-end: cloned stripe.com builds clean (next build, 4/4 pages),
served, and `fidelity` scored it 63/100 (motion 52 — the gap competitors miss).
-o now writes where asked. Adds a clone --fidelity CLI smoke test.
README quick-start, a dedicated 'Measured clone fidelity' section, the plugin
command table, and the full CLI reference now cover /fidelity and /gallery.
CHANGELOG 12.24.0 entry. Version bumped in lockstep across package.json,
plugin.json, marketplace.json (manifests describe the two new commands); the
version-sync test now guards fidelity + gallery too.
designlang gallery [dir] scans a directory for fidelity.json reports, builds the
gallery model, and writes a deployable static site (index + permalink pages +
OG cards) to --out. load.js does a safe recursive scan (skips node_modules and
malformed files). Adds command doc + loader tests + 2 CLI smoke tests. Verified
end-to-end against real reports.
formatGalleryIndex renders a dark card grid (best-first) with summary stats;
formatGalleryItemPage renders a permalink page that embeds the SVG score card,
OG/Twitter meta (so a shared link unfurls the number), a motion table, and the
ranked correction plan. renderGallerySite emits the full [{path,content}] tree
(index + per-clone page + per-clone card.svg). 4 unit tests.
The distribution surface for the fidelity loop (feature C). buildGallery() turns
a set of fidelity reports into a deduped, best-first item list with summary
stats (count, avg, best/worst). slugify() + toGalleryItem() are tolerant of
partial reports. Pure data shaping; 6 unit tests.
Renders a combined fidelity result three ways: machine JSON, a Markdown report
(headline score, motion breakdown table, ranked correction plan), and a
self-contained SVG share card ('88% · B · stripe.com'). The card is the gallery
share hook — a measured number no competitor can post. 6 unit tests.
The wedge neither competitor ships: don't just measure fidelity — prescribe the
next edit and loop on it.
- correction-plan.js: turns verify's per-token-family attribution and the motion
aspect scores into ranked, actionable directives (hardest-hitting first), each
with an expectedGain heuristic so the loop spends its budget well.
- index.js: combineFidelity() blends visual (verify) + motion into one score and
letter grade; runFidelityLoop() drives measure→plan→rebuild→re-measure until a
threshold or round budget, with measure/rebuild injected for offline testing.
10 unit tests covering ranking, threshold gating, blend math, convergence, the
stall path, and the no-directives early exit.
verify/ scores static visual fidelity but deliberately ignores motion. This
fills that gap: a pure function over two extractMotion() outputs that blends
seven aspects (feel, durations, easings, springs, keyframe kinds, scroll-linked,
choreography/stagger) into a 0-100 motion fidelity score with per-aspect gaps.
Missing-on-clone motion is the dominant penalty; over-animation is penalised
gently. No browser, no I/O — fully unit-tested (7 cases).
13 tests: warm/cool axis polarity, archetype selection from the twelve,
adjective/positioning/evidence shape, dangling-dash guard, empty/null
robustness, string-form inputs, and Essence chapter in html + md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The media-dark capture runs on the shared page before the --depth crawl
navigates it away, so it must restore the scheme to light afterwards or
the crawl reads dark colours off internal pages. Adds two regressions:
restore-after-success and restore-on-evaluate-throw (the try/finally).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Asserts no literal [object Object], semantic/easing/z-index values render,
and component class names keep their last character.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Synthesis reduced swatches to {hex,count}, dropping rgb/contexts and
crashing formatMarkdown (reads c.contexts.join and c.rgb) when the
canonical pack was emitted. Keep the whole swatch (spread) so every
emitter works. Caught by an end-to-end run on example.com; covered by a
regression test asserting canonical swatches retain rgb + contexts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/check-plugin-version.mjs fails loud when plugin.json or
marketplace.json drift from package.json; wired as 'npm run check-plugin'
and enforced by tests/plugin-version.test.js (also asserts every new
command has a backing file and is described). Prevents the drift recurring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New Motion tab in the local studio renders extracted easing as SVG curves
(springs styled distinctly), duration chips, and runtime choreography +
scroll recipes. Click a curve to preview its timing; play a choreography
sequence to see the real captured stagger. Degrades to a --motion-runtime
prompt when no motion tokens are present.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
motion-tokens.json gains choreography + scroll-recipe + observed-duration
blocks. Framer Motion stagger and the new GSAP stagger() helper now use the
real runtime-observed staggerMs instead of a heuristic guess when
--motion-runtime captured one. Degrades cleanly with no runtime data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure processing layer for Motion Lang v3: processRuntimeMotion() turns raw
document.getAnimations() observations into a structured per-trigger model with
real durations; detectChoreography() finds evenly-staggered sequences;
detectScrollRecipes() classifies scroll motion into parallax/reveal/pin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The preview felt random. This rebuilds it as a deliberate design-system
specimen instead of a scatter of placeholder components.
- Composed preview: masthead, a centered hero using the site's own strongest
heading + top CTA verbs, labeled Color and Type foundations, then Buttons /
Inputs / Status / In-context cards / Metrics — each under a clear section
eyebrow, on a consistent vertical rhythm. The rebuilt-Page tab reuses the
same crafted components. Honest product copy, no lorem.
- Harmonised deriveTokens: guarantee a legible surface/text pair, filter greys
out of accent selection (no neutral promoted to brand), deepen a near-
invisible accent to read on the surface, hold secondary text above a
legibility floor. Keep white-on-warm-accent convention for accent foreground.
- Calmer chrome: quieter hairlines, consistent 30px rounded controls, pill
tabs, segmented light/dark — the frame recedes so the system is the focus.
Shared studio engine, so the CLI and website /studio get it identically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Light/Dark toggle that generates a dark variant from the extracted
light tokens: surface → near-black tinted toward the brand hue, text →
near-white, muted/border/card recomputed; the brand accent is preserved
(lightened only if it would vanish on dark). Type/shape/spacing/motion carry
over. Contrast grading recomputes live, inspector edits layer on top of
either theme, and the theme is encoded in the shareable URL.
Pure shared deriveDark() in src/studio-tokens.js keeps the CLI and website
studios in lock-step. New unit tests cover the dark derivation and control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn `designlang studio` from a read-only viewer into an interactive
playground, and ship the same experience on the website.
CLI (`designlang studio`):
- Inspector (color/type/shape/spacing/motion) writes a small `--p-*` CSS
variable set; a tabbed live preview — component wall + a page rebuilt from
the extraction's reading order — restyles instantly off those variables.
- Live WCAG contrast grading per color, paper/white/dark backdrops, an edit
counter, and smooth token-driven transitions.
- Export edited system as DTCG tokens / CSS variables / Tailwind theme
(zero-dep client downloads); share via URL (edits encoded as hash deltas).
Website (`/studio`):
- Paste any URL → extract → embed the identical editable studio in a framed
viewport. Shareable via `?url=`; shares the free 2/day demo budget.
- New POST /api/studio reuses the same `studioHtml` engine server-side.
Internals:
- Token derivation extracted to a pure, dep-free module (src/studio-tokens.js)
shared by the CLI and the website route, keeping both surfaces in lock-step.
- New unit tests: derivation, WCAG contrast math, rendered studio document.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`designlang verify <url>` closes the extract→prove loop. For each detected
component (button+card in v1) it rebuilds a clone styled ONLY by the extracted
tokens (each visual property snapped to nearest token, colours in CIE-Lab ΔE),
renders it, and pixel-diffs against the live component → 0-100 fidelity score +
per-token-family attribution + a verify.html triptych (original | rebuilt |
loss heatmap) + verify.json. `--min` gates CI.
Honest by construction: missing component → n/a (excluded, no silent 100);
property with no token → unmapped (counted as loss, surfaced in attribution);
size mismatch letterboxed, never stretched.
New isolated modules under src/verify/ (tokens/restyle/render/diff/index) +
formatters/verify.js + verify CLI command. Adds pixelmatch+pngjs. 13 unit tests,
full suite green (438). Live smoke vs github.com: button 89/100.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Two new framework targets for the extracted motion language:
- motion-gsap.js: registers extracted cubic-bezier curves as named GSAP
CustomEases, emits gsap.from() reveal helpers + a ScrollTrigger batch
reveal when the page uses scroll/view-timeline.
- motion-waapi.js: zero-dependency Web Animations API preset. WAAPI takes
cubic-bezier easing strings verbatim, so curves reproduce exactly (no
spring approximation); helpers honour prefers-reduced-motion.
Wired into the CLI emit list (`<host>-motion.gsap.js`, `-motion.waapi.js`),
the /api/motion exports, and the motionlang analyzer export cards. Adds 8
emitter tests (425 -> all green). Also ignores .npmrc.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
motionlang:
- New formatter src/formatters/motion-css.js — <host>-motion.css with
--duration-*/--ease-* custom properties, reusable + reconstructed
@keyframes, .mo-* utility classes, and a prefers-reduced-motion guard.
- New formatter src/formatters/motion-tailwind.js — <host>-motion.tailwind.js
require-able theme.extend (transitionDuration, transitionTimingFunction
incl. a spring curve from detected overshoot beziers, keyframes, animation).
- Wired both through api.js (RENDERERS + ext map) and the bin files array as
the motion-css and motion-tailwind renderer ids.
- 13 new tests; full suite 414 pass.
website PDF fix ("downloaded PDF won't open"):
- extract route awaits the Blob cache write before the stream closes, so the
serverless instance can't freeze mid-write and leave /api/pdf/<hash> with
no cached design (was a fire-and-forget write returning 404 JSON).
- ShareExtractionButton fetches + validates content-type is application/pdf
before saving, surfacing an error instead of writing a broken file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New formatter src/formatters/motion-one.js — emits <host>-motion.one.js
with animate() helpers (fadeIn, slideUp, scaleIn, pop), springs derived
from detected overshoot beziers, reconstructed keyframe maps, and
inView/scroll helpers when the source uses scroll-/view-timeline.
- Framer Motion: springs now derived from detected overshoot beziers
(was hard-coded), added pop variant on a spring, keyframe-derived
variants reconstructed from @keyframes, and a whileInView inView
block when the source uses scroll motion.
- Wire motion-one through api.js (RENDERERS + ext map) and the
bin/design-extract.js files array so it ships alongside the existing
Framer Motion preset.
- Tests: 7 new cases covering spring derivation, scroll-conditional
emit, keyframe reconstruction, and minimal-design degradation. All
pass; 154 existing formatter tests still green.
Shared springFromBezier heuristic keeps Motion One and Framer Motion
outputs feel-consistent for the same site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds native PDF brand-guide output to the brand command:
--pdf, --paper a4|letter|tabloid, --landscape,
--no-print-background, --attach-tokens
PDF features: per-chapter page breaks, running footer with page
numbers, selectable text, embedded fonts, optional DTCG-tokens
JSON attached as a file inside the PDF (paperclip in Acrobat).
One new dep: pdf-lib (lazy-imported, only used when --attach-tokens
is passed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two tight quality-of-life additions on top of the v12.9 extraction pass.
stats command
$ npx designlang stats stripe.com
Grade B · 87/100
Primary #533afd ×899 59% conf
Fonts sohne-var
Type scale 14 sizes
Spacing base 2px · 13 steps
Shape 5 radii · 6 shadows
Colours 31 tokens
WCAG 79%
Stack next · unknown
Material skeuomorphic
Tone neutral
Intent landing
One-screen summary, no files written. Use -j / --as-json for
machine-readable output (CI, scripting).
(--json is already a global program flag for the main extract
command's raw-JSON-to-stdout mode, so the subcommand flag is
--as-json to avoid the clash. Both --as-json and -j shortcuts work.)
Low-confidence note in grade.html
v12.9 added primary.confidence (0-1) on the colours payload but no
surface used it. v12.10 renders an amber callout above the
dimensions grid in the grade report card when confidence < 0.5:
"Primary detection was low-confidence (32%). The brand colour
may be a soft pick — review the palette below."
Soft inline note, not an error. Stays hidden on the common high-
confidence path. Dark-mode aware.
Tests
- 2 new tests cover the present + absent paths of the confidence
note.
- 398/398 total.
No breaking changes, no new dependencies.