- .github/workflows/bench.yml: manual (choose a set) plus weekly, installs the
browser, runs held-out and training sets, writes both tables to the run
summary and uploads bench/results as an artifact. Nothing is auto-committed.
- the report names the set it ran (a held-out run said bench/sites.json) and
says when it is the held-out set
- median time rounds to one decimal (it printed 30.349999999999998s)
Runs were recorded as timed out while still going: 363s for a 180s cap on
designlang, 3029s once for dembrandt through npx. Killing the direct child left
grandchildren (npx's node, Playwright's Chromium in its own process group)
alive and holding stdout open, and the runner waited for the pipes to close.
- each tool runs detached with its own TMPDIR, where the browser profile lands;
on timeout the process group is killed, plus anything whose command line
names that directory, and the result is recorded without waiting for pipes
- a timeout is recorded as a timeout, timed at the kill rather than after cleanup
- a run that fails with a network-level error (ERR_INTERNET_DISCONNECTED and
similar) is retried once for either tool; the report counts retries
- BENCH_TIMEOUT_MS overrides the 180s cap
Verified with a 15s cap on airbnb.com: both runs end at 15s and no browser
processes remain.
Same sourcing and live-verification rules as bench/sites.json. Each colour
records whether its hex is stated in official material or only in secondary
sources. 15 sites are scored on colour and 14 on font; three are excluded with
the reason recorded.
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)
designlang 13.3.0 vs dembrandt 0.33.0 on 27 sites:
primary colour 9/17 vs 12/17, body font 25/27 vs 25/27, median 12.6s vs 24.6s.
Kept as the baseline so fixes are measured against it, not a moving target.
Also print unscored dimensions as - rather than a miss in the console.
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
Brand colours come from official brand guidelines and design-system docs, and
count only when bench/evidence.mjs finds them painted on the live homepage;
fonts are the family that actually renders body copy. 17 sites are scored on
colour, all 27 on font. Three sites are excluded with the reason recorded.
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
Adds author, repository, homepage and bugs to package.json so the npm package links back to this repo, a byline under the README badges, and points the footer credit at www.manavaryasingh.com.
Claude-Session: https://claude.ai/code/session_01BuAT6TU8R8GHQh5CLPHNxJ
DESIGN.md (the agent-native single file) now carries the tonal ramps,
measured contrast pairs and area dominance in Colors; the ratio, measure,
fluid summary and named scale in Typography; the content column, column
system, section rhythm and gap scale in Layout; and the elevation ladder
with its redundancy figure in Elevation.
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.
README gains a section on what `dna` measures and — as important — the three
things it refuses to do: invent a missing measurement, print a number
without its evidence, or compare across vector versions.
Registers /dna as the fourteenth plugin command and bumps the manifests.
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.
`dna <url>` places a site in the design space and prints where it landed;
`dna-corpus <urls...>` builds the reference frame it ranks against, so teams
can measure against their own products or competitors rather than the
shipped corpus.
`grade` gains one line naming the nearest design system beside the letter.
A grade with no reference frame is half a claim. It is wrapped so DNA can
never be the reason a grade fails.
One unreachable site during a corpus build is reported and skipped rather
than discarding every other extraction in the run.
`analyze()` places an extracted design in the space; `writeDnaOutputs()`
emits the vector plus a readable report. Both stay pure of CLI concerns so
the pieces remain testable on their own.
The shipped corpus is extracted live from linear, vercel, stripe, notion,
duolingo, netflix, figma and railway, so `designlang dna` has a reference
frame out of the box. A missing corpus is treated as "no reference frame"
rather than an error — the vector is still worth writing.
Everything else produces numbers; this says what they mean without
overclaiming. It never prints a distance without how many features backed
it, and never a percentile without naming the corpus it ranks against — a
design score with no stated reference frame is the thing this replaces.
Percentiles are framed as ranks, not judgements. A design far from the
middle of the corpus is unusual, which may be exactly the intent.
A vector on its own says nothing — "radius 0.31" only means something next
to what real design systems do. The corpus is that reference frame, and
percentiles turn an absolute measurement into a rank against it.
`outliers()` answers the question this whole feature exists for: what makes
a design look the way it does, as the features furthest from the middle of
the corpus.
Loading checks the corpus version before anything compares against it. A
silently stale corpus is worse than no corpus, because it still produces
confident percentiles.
Mean absolute difference across the features both designs actually have,
reported per axis as well as overall — "0.31 apart, mostly on motion" is
the finding; a single opaque number is not.
Mean-absolute rather than Euclidean because the output has to be readable:
0.31 means the average feature is 31% of its range apart, which a person can
reason about. Nobody has intuition for Euclidean distance in 30 dimensions.
Every result carries the count of features that backed it. A distance from
four surviving features is a weaker claim than one from thirty, and callers
are given what they need to say so.
Every designlang command so far answers what a design uses. None answers
where it sits, because there is no coordinate system to place it in.
`features.js` reads 30 measurements off an already-extracted design across
five axes — colour, type, space, shape, motion — keeping them in their
natural units so they stay readable in reports. `vector.js` normalizes them
into 0..1 against the ranges real design systems occupy.
Two rules make the numbers mean something later:
* FEATURE_ORDER and the normalizers are frozen per VECTOR_VERSION, so a
score published today still means the same thing next year.
* A measurement the page never exposed stays null the whole way through.
Substituting a midpoint would be indistinguishable from a real one and
would quietly move the design in the space.
The root command declares both a positional `<url>` and `-o, --out`, so
Commander claimed any option appearing after a subcommand for the root
command, where the root's own `-o` shadowed the subcommand's. Every
subcommand's `-o`/`-n` was silently dropped and output always landed in
`./design-extract-output`, whatever the user asked for.
`enablePositionalOptions()` gives options typed after a subcommand back to
that subcommand. This also fixes three PDF renderer tests that had been
failing because the flags they passed never reached the command.
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.