@designesy/score 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @designesy/score
2
2
 
3
- Design quality gate for AI-generated UI: score any URL on 44 deterministic checks, A–F grade. This is the standalone engine: it fetches a URL, extracts CSS + `:root` tokens, and runs every check that needs no browser locally, with **no Designesy server required**; the browser checks return MANUAL. No required dependencies.
3
+ Design quality gate for AI-generated UI: score any URL on 45 deterministic checks, A–F grade. This is the standalone engine: it fetches a URL, extracts CSS + `:root` tokens, and runs every check that needs no browser locally, with **no Designesy server required**; the browser checks return MANUAL. No required dependencies.
4
4
 
5
5
  ## Install
6
6
 
@@ -54,7 +54,7 @@ console.log(result.fail); // 3
54
54
  console.log(result.checks); // [{ id: 'v01', status: 'PASS', detail: '...' }, ...]
55
55
  ```
56
56
 
57
- ## The 44 checks
57
+ ## The 45 checks
58
58
 
59
59
  | Category | Checks | Weight |
60
60
  |---|---|---|
@@ -85,7 +85,7 @@ Plus 12 anti-slop deductions (S1 to S12) and 7 originality lifts (O1 to O7).
85
85
 
86
86
  ## Scope system
87
87
 
88
- - **contract** (default for designesy.org): All 44 checks active. Absence = WARN/FAIL.
88
+ - **contract** (default for designesy.org): All 45 checks active. Absence = WARN/FAIL.
89
89
  - **universal** (default for external sites): Optional features SKIP on absence instead of penalizing.
90
90
 
91
91
  Auto-detect: `designesy.org` → contract, everything else → universal.
package/dist/cli.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @designesy/score CLI — Score a URL against the 44-check Designesy engine.
3
+ * @designesy/score CLI — Score a URL against the 45-check Designesy engine.
4
4
  *
5
- * Runs the full 44-check scoring engine locally — no server required.
5
+ * Runs the full 45-check scoring engine locally — no server required.
6
6
  * Fetches the target URL, extracts CSS + :root tokens, runs all checks,
7
7
  * and prints a formatted report. Exits with code 1 when the score drops
8
8
  * below --min-score or the grade drops below --min-grade.
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @designesy/score CLI — Score a URL against the 44-check Designesy engine.
3
+ * @designesy/score CLI — Score a URL against the 45-check Designesy engine.
4
4
  *
5
- * Runs the full 44-check scoring engine locally — no server required.
5
+ * Runs the full 45-check scoring engine locally — no server required.
6
6
  * Fetches the target URL, extracts CSS + :root tokens, runs all checks,
7
7
  * and prints a formatted report. Exits with code 1 when the score drops
8
8
  * below --min-score or the grade drops below --min-grade.
@@ -98,10 +98,10 @@ function formatReport(result, url) {
98
98
  }
99
99
  function printUsage() {
100
100
  console.log(`
101
- designesy-score: Score a URL against the 44-check Designesy engine.
101
+ designesy-score: Score a URL against the 45-check Designesy engine.
102
102
 
103
103
  Runs locally: no server required. Fetches the URL, extracts CSS + tokens,
104
- and runs all 44 checks in one process.
104
+ and runs all 45 checks in one process.
105
105
 
106
106
  Usage:
107
107
  designesy-score <url> [options]
@@ -192,7 +192,7 @@ async function main() {
192
192
  // Progress and the gate verdict go to stderr: stdout carries only the
193
193
  // result, so --json and the JSON formats pipe straight into a parser.
194
194
  if (!args.quiet) {
195
- console.error(`${DIM}Scoring ${url} locally (44-check engine, scope=${scope || 'auto'})…${RESET}`);
195
+ console.error(`${DIM}Scoring ${url} locally (45-check engine, scope=${scope || 'auto'})…${RESET}`);
196
196
  }
197
197
  let result;
198
198
  try {
package/dist/engine.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * @designesy/score — 44-check design-contract scoring engine.
2
+ * @designesy/score — 45-check design-contract scoring engine.
3
3
  *
4
4
  * Scores a URL against the Designesy design-system contract: fetches the page,
5
- * extracts CSS + :root tokens, runs 44 checks (41 scored, 3 that need a
5
+ * extracts CSS + :root tokens, runs 45 checks (42 scored, 3 that need a
6
6
  * browser), computes a weighted score (0-100), assigns a letter grade (A-F),
7
7
  * and applies anti-slop deductions + originality lifts.
8
8
  *
@@ -159,7 +159,7 @@ export declare function scoreFromParts(input: ScorePartsInput): Promise<ScoreRes
159
159
  * the site served v0.4.3; test/engine-1-2-0.test.mjs now reads the site's
160
160
  * contract source and fails when the two differ.
161
161
  */
162
- export declare const CONTRACT_VERSION = "v0.4.5";
162
+ export declare const CONTRACT_VERSION = "v0.4.6";
163
163
  /**
164
164
  * The one verdict rule, shared by every emission format.
165
165
  *
package/dist/engine.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * @designesy/score — 44-check design-contract scoring engine.
2
+ * @designesy/score — 45-check design-contract scoring engine.
3
3
  *
4
4
  * Scores a URL against the Designesy design-system contract: fetches the page,
5
- * extracts CSS + :root tokens, runs 44 checks (41 scored, 3 that need a
5
+ * extracts CSS + :root tokens, runs 45 checks (42 scored, 3 that need a
6
6
  * browser), computes a weighted score (0-100), assigns a letter grade (A-F),
7
7
  * and applies anti-slop deductions + originality lifts.
8
8
  *
@@ -481,10 +481,11 @@ const REMEDIATION = {
481
481
  v43: 'Express UI states as semantic color roles: --ok/--success (verification pass), --warn/--warning (caution), --error/--danger (failure), --info (notice). The contract ships --ok, --warn, and --error for exactly this. Status colors named by state let components consume meaning (a score badge, a form error, and a toast all read the same token) and stay legible when the palette evolves.',
482
482
  v44: 'Give each status hue a text token for words and keep the hue itself for marks. A color that marks a state (a dot, a bar, a tint) needs 3:1, while the word for that state needs 4.5:1 (3:1 at 24px, or 18.66px bold) in every theme the site declares. Mix the hue toward your ink for text, for example --warn-ink: color-mix(in oklab, var(--warn) 70%, var(--ink)), measure it on the page and on each surface it sits on in every theme, and paint status words with it.',
483
483
  v45: 'Let every entrance that starts invisible run to its end when motion is paused. Under the pause rule, give one-shot entrances whose first keyframe is opacity 0 a negative delay longer than the animation, for example html[data-motion="paused"] :is(.fade-up, .reveal) { animation-delay: -3600s !important; }, or remove them with animation: none. Loops can stay held. Test by pausing motion and reloading: every page should still show its content.',
484
+ v46: 'Size text from the type scale. Use a type token (font-size: var(--text-body)) or a size that sits on the scale your tokens declare; keep deliberate fluid type in clamp() with a viewport or container unit, and keep its lower bound at or above the smallest type token. A static size between two steps reads as a one-off and drifts the rhythm the scale exists to hold.',
484
485
  v34: 'If the page hosts an AI chatbot or agent, tell people they are talking to an AI no later than their first interaction, in a form assistive technology announces. EU AI Act Article 50(1), which applies from 2026-08-02, places this duty on the provider of the AI system (the organisation that develops it and places it on the market or puts it into service under its own name); it does not apply where the AI nature is obvious from the context, and Article 50(5) asks for the information to be clear and accessible. The check reads any one of: visible "AI assistant" or "Chatbot" text, aria-label="AI assistant" on the chat container, or a data-ai-disclosure attribute on the widget. A live chat answered by people needs no AI disclosure: if the check flagged one for review, confirm who answers. US laws differ in scope: California\'s bot-disclosure law (SB 1001, Bus. & Prof. Code §§17940-17943) covers bots that mislead people about being bots to drive a sale or influence a vote; Colorado SB 24-205 was repealed and re-enacted by SB 26-189 (signed 2026-05-14, obligations from 2027-01-01), which governs automated decision-making in consequential decisions. This is not legal advice.',
485
486
  v35: 'Add a forced-colors readiness block: @media (forced-colors: active) { ... } with forced-color-adjust: none on elements that must preserve brand identity (logos, charts, semantic-color indicators). Windows High Contrast Mode and Chrome forced-colors recolor the page: without this media query, critical UI becomes illegible. Also ensure borders/outlines use currentColor or system colors so they adapt. Test with Windows HCM (Settings > Accessibility > Contrast themes).',
486
487
  v36: 'Remove UTS #39 confusable characters from CSS identifiers and token names. Confusables are Unicode characters from different scripts (Cyrillic, Greek, fullwidth) that look identical to ASCII letters; Cyrillic а (U+0430), for example, looks like Latin a (U+0061). In token names they enable shadowing attacks (--соlor-bg with Cyrillic с vs --color-bg). Audit all custom property names, class names, and url() paths for non-ASCII characters using a Unicode confusable detector. Provenance: Unicode Technical Standard #39, Unicode 16.0.0. Designesy is the only design verification engine that checks this surface.',
487
- v37: 'Publish a DESIGN.md file at /DESIGN.md in your repo root and serve it publicly. Google\'s @google/design.md CLI (v0.4.0, Apache-2.0) validates the file format: 11 lint rules covering broken token refs, missing primary colors, WCAG contrast, orphaned tokens, section order, and more. Designesy integrates Google\'s linter as the spec layer and runs its own 44-check contract verification as the layer above. Install the CLI: npm install -g @google/design.md. Lint locally: npx @google/design.md lint DESIGN.md. Export to W3C DTCG: npx @google/design.md export --format dtcg DESIGN.md. Note: DESIGN.md uses sRGB hex only; for OKLCH/Display P3 color spaces, use the W3C DTCG JSON format directly.',
488
+ v37: 'Publish a DESIGN.md file at /DESIGN.md in your repo root and serve it publicly. The DESIGN.md format\'s linter (v0.4.0, Apache-2.0) validates the file: 11 lint rules covering broken token refs, missing primary colors, WCAG contrast, orphaned tokens, section order, and more. Designesy integrates Google\'s linter as the spec layer and runs its own 45-check contract verification as the layer above. Install the CLI: npm install -g @google/design.md. Lint locally: npx @google/design.md lint DESIGN.md. Export to W3C DTCG: npx @google/design.md export --format dtcg DESIGN.md. Note: DESIGN.md uses sRGB hex only; for OKLCH/Display P3 color spaces, use the W3C DTCG JSON format directly.',
488
489
  v38: 'Rewrite button labels to start with a verb or recognized command. NN/g: "Lead with verbs or verb phrases that clearly outline what will happen after the command is selected." Use "Save changes" not "Changes", "Delete file" not "File". Recognized commands: Save, Cancel, Delete, Edit, Share, Close, Back, Next, etc. This is a WARN (heuristic): review flagged buttons manually.',
489
490
  v39: 'Remove trailing periods from button text, labels, and tab text. Microsoft Fluent: "Don\'t end text for buttons, radio buttons, labels, or checkboxes with a period." Periods are for full sentences in tooltips, error messages, and dialog bodies only.',
490
491
  v40: 'Replace non-descriptive link text with destination-revealing text. WCAG 2.4.4 Link Purpose: link text should describe the destination. Use "Read the typography guide" not "Click here". Use "View the leaderboard" not "Learn more". NN/g: non-descriptive links force users to read surrounding context to understand the destination.',
@@ -1138,6 +1139,39 @@ function isFieldSelector(selector) {
1138
1139
  || /\.(?:input|field)(?![\w-])/i.test(subject);
1139
1140
  });
1140
1141
  }
1142
+ /**
1143
+ * v27 helper (engine 1.4.1): the selector of the rule whose body holds the
1144
+ * declaration at `offset`, read backwards from that body's opening brace to
1145
+ * the previous closing brace, opening brace or semicolon. Null when the offset
1146
+ * is not inside a rule body (a stray declaration, or a brace closed in
1147
+ * between). Linear: a few lastIndexOf scans per call, no whole-sheet regex.
1148
+ */
1149
+ function ruleSelectorAt(css, offset) {
1150
+ const open = css.lastIndexOf('{', offset);
1151
+ if (open < 0 || css.lastIndexOf('}', offset) > open)
1152
+ return null;
1153
+ const start = Math.max(css.lastIndexOf('}', open - 1), css.lastIndexOf('{', open - 1), css.lastIndexOf(';', open - 1)) + 1;
1154
+ return css.slice(start, open);
1155
+ }
1156
+ /**
1157
+ * v27 helper (engine 1.4.1): does the font-size declaration at `offset` sit in
1158
+ * a rule whose own selector names a field keyword (input, textarea, select,
1159
+ * .input, .field), the same words the literal scan looks for? v27's literal
1160
+ * regexes find a keyword and then the next rule body, and their `[^{]*` runs
1161
+ * across a closing brace. So the word "select" in `.chip { user-select: none }`
1162
+ * carried into `.chip-label { font-size: 12px }` and FAILed a page whose
1163
+ * fields were all 16px. The regexes still find the candidates; this reads the
1164
+ * selector of the rule each matched declaration actually sits in, and drops a
1165
+ * candidate whose own selector carries no keyword. Class names stay matched
1166
+ * as before (`.search-input`, `.form-select`): many sites style their fields
1167
+ * through such classes, and measured on the leaderboard cohort the stricter
1168
+ * isFieldSelector test dropped real fields (a 14px textarea, a 16px search
1169
+ * input's floor).
1170
+ */
1171
+ function inFieldRule(css, offset) {
1172
+ const selector = ruleSelectorAt(css, offset);
1173
+ return selector !== null && /input|textarea|select|\.field/i.test(selector.replace(/\/\*[\s\S]*?\*\//g, ' '));
1174
+ }
1141
1175
  /**
1142
1176
  * v27 helper (engine 1.3.1): in each rule whose selector styles a field
1143
1177
  * (isFieldSelector), every `font-size: var(...)` declaration is rewritten to
@@ -1159,12 +1193,9 @@ function resolveFontSizeVars(css, tokens) {
1159
1193
  if (css.indexOf('var(') < 0)
1160
1194
  return css;
1161
1195
  return css.replace(/(font-size\s*:\s*)(var\([^;{}]*\))/gi, (decl, lead, value, offset) => {
1162
- const open = css.lastIndexOf('{', offset);
1163
1196
  // Not inside a rule body (a stray declaration, or a brace closed between).
1164
- if (open < 0 || css.lastIndexOf('}', offset) > open)
1165
- return decl;
1166
- const start = Math.max(css.lastIndexOf('}', open - 1), css.lastIndexOf('{', open - 1), css.lastIndexOf(';', open - 1)) + 1;
1167
- if (!isFieldSelector(css.slice(start, open)))
1197
+ const selector = ruleSelectorAt(css, offset);
1198
+ if (selector === null || !isFieldSelector(selector))
1168
1199
  return decl;
1169
1200
  const sub = substituteCssVars(value, [tokens]);
1170
1201
  return sub === null ? decl : lead + sub.trim();
@@ -1210,6 +1241,9 @@ function checkInputFontFloor(css, html, tokens = {}) {
1210
1241
  const below = [];
1211
1242
  let m;
1212
1243
  while ((m = inputRe.exec(sheet)) !== null) {
1244
+ // Engine 1.4.1: only a declaration whose own rule styles a field counts.
1245
+ if (!inFieldRule(sheet, m.index + m[0].lastIndexOf('font-size')))
1246
+ continue;
1213
1247
  const val = parseFloat(m[1]);
1214
1248
  const unit = m[2].toLowerCase();
1215
1249
  const px = unit === 'rem' ? val * 16 : val;
@@ -1220,8 +1254,13 @@ function checkInputFontFloor(css, html, tokens = {}) {
1220
1254
  // Also check: is there a global input font-size ≥16px? (e.g. `input { font-size: 1rem }`)
1221
1255
  // The strict form `input\s*\{` matches standalone rules; the grouped form
1222
1256
  // `input,...,{` matches selectors merged by CSS minifiers (e.g. `input,textarea,select{font-size:1rem}`).
1223
- const hasGlobalFloor = /input\s*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(sheet)
1224
- || /input\s*[,][^{]*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(sheet);
1257
+ // Engine 1.4.1: the floor rule must itself style a field, as above.
1258
+ const floorRe = /input\s*(?:,[^{]*)?\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/gi;
1259
+ let hasGlobalFloor = false;
1260
+ let f;
1261
+ while (!hasGlobalFloor && (f = floorRe.exec(sheet)) !== null) {
1262
+ hasGlobalFloor = inFieldRule(sheet, f.index + f[0].lastIndexOf('font-size'));
1263
+ }
1225
1264
  if (hasGlobalFloor)
1226
1265
  return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'PASS', detail: 'input font-size floor detected' };
1227
1266
  if (!hasZoomableField(html))
@@ -2842,6 +2881,143 @@ function isDocumentScope(compound) {
2842
2881
  return simple.every((s) => s.startsWith('.') || s.startsWith('['))
2843
2882
  && simple.some((s) => V45_TOGGLE_ATTR.test(s) || V45_TOGGLE_CLASS.test(s));
2844
2883
  }
2884
+ // v46 — Type sizes come from the scale (engine 1.4.0).
2885
+ //
2886
+ // v16 asks that sizes be written in rem; it cannot tell a rem that sits on the
2887
+ // type scale from one that sits between steps, so a page can pass v16 while its
2888
+ // sizes drift off the scale its own tokens declare. v46 reads the scale from the
2889
+ // page itself: the :root type-size tokens (a name such as --text-*, --font-size-*,
2890
+ // --fs-* or --type-*, whose value resolves to a static rem or px length; colour
2891
+ // tokens named --text-muted never resolve to a length, so they never count). The
2892
+ // steps are every token value plus the token ratio (the median of consecutive
2893
+ // token ratios) extended four steps below and ten above the token nearest 16px.
2894
+ // The floor is the smallest type token.
2895
+ //
2896
+ // Fluid type is a deliberate design move, so a clamp(), min() or max() that
2897
+ // moves with a viewport or container unit is accepted as written; only its lower
2898
+ // bound is held to the floor. Relative sizes (em, %, inherit, keywords) follow
2899
+ // their parent, not the global scale, and are not judged. A static rem or px
2900
+ // size is judged: off the scale when it sits more than 2.5% from every step,
2901
+ // below the floor when it is smaller than the smallest type token.
2902
+ //
2903
+ // WARN only: an off-scale size is a hygiene finding, not a broken page.
2904
+ const V46_ITEM = 'Type sizes come from the scale';
2905
+ const V46_TOLERANCE = 0.025;
2906
+ const V46_FLUID = /(?:^|[^a-z-])(?:[sld]?v[wh]|v[ib]|vmin|vmax|cq[iwhb]|cqmin|cqmax)\b/i;
2907
+ function v46StaticPx(value) {
2908
+ const m = value.trim().match(/^(\d*\.?\d+)(rem|px)$/i);
2909
+ if (!m)
2910
+ return null;
2911
+ return parseFloat(m[1]) * (m[2].toLowerCase() === 'rem' ? 16 : 1);
2912
+ }
2913
+ function v46TypeScale(tokens) {
2914
+ const values = [];
2915
+ for (const [name, raw] of Object.entries(tokens)) {
2916
+ if (!/^--(?:text|font-size|fs|type)-[\w-]+$/i.test(name))
2917
+ continue;
2918
+ const px = v46StaticPx(resolveVar(raw, tokens));
2919
+ if (px !== null && px > 0)
2920
+ values.push(px);
2921
+ }
2922
+ const unique = [...new Set(values.map((v) => Math.round(v * 1000) / 1000))].sort((a, b) => a - b);
2923
+ if (unique.length < 3)
2924
+ return null;
2925
+ const ratios = [];
2926
+ for (let i = 1; i < unique.length; i++) {
2927
+ const r = unique[i] / unique[i - 1];
2928
+ if (r >= 1.05 && r <= 1.7)
2929
+ ratios.push(r);
2930
+ }
2931
+ ratios.sort((a, b) => a - b);
2932
+ const ratio = ratios.length ? ratios[Math.floor(ratios.length / 2)] : 1.25;
2933
+ const base = unique.reduce((best, v) => (Math.abs(v - 16) < Math.abs(best - 16) ? v : best), unique[0]);
2934
+ const steps = new Set(unique);
2935
+ for (let n = -4; n <= 10; n++)
2936
+ steps.add(Math.round(base * Math.pow(ratio, n) * 1000) / 1000);
2937
+ return { steps: [...steps].sort((a, b) => a - b), floor: unique[0], ratio, count: unique.length };
2938
+ }
2939
+ /** Lower bound in px of a fluid size: clamp()'s first argument, or the largest static argument of max(). */
2940
+ function v46FluidFloor(value) {
2941
+ const fn = value.trim().match(/^(clamp|max)\((.*)\)$/i);
2942
+ if (!fn)
2943
+ return null;
2944
+ const args = splitCssTopLevel(fn[2], ',').map((a) => v46StaticPx(a));
2945
+ if (fn[1].toLowerCase() === 'clamp')
2946
+ return args.length === 3 ? args[0] : null;
2947
+ const statics = args.filter((a) => a !== null);
2948
+ return statics.length ? Math.max(...statics) : null;
2949
+ }
2950
+ function v46Selector(css, at) {
2951
+ const open = css.lastIndexOf('{', at);
2952
+ if (open < 0)
2953
+ return '';
2954
+ const start = Math.max(css.lastIndexOf('}', open), css.lastIndexOf(';', open), css.lastIndexOf('{', open - 1)) + 1; // stop at an enclosing @media brace too
2955
+ return css.slice(start, open).replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
2956
+ }
2957
+ function checkTypeScale(css, tokens) {
2958
+ const scale = v46TypeScale(tokens);
2959
+ if (!scale) {
2960
+ return { id: 'v46', item: V46_ITEM, category: 'cadence', status: 'SKIP', detail: 'fewer than 3 type-size tokens in :root: no scale to measure against' };
2961
+ }
2962
+ const { steps, floor, ratio } = scale;
2963
+ const nearest = (px) => steps.reduce((best, s) => (Math.abs(s - px) < Math.abs(best - px) ? s : best), steps[0]);
2964
+ const onScale = (px) => steps.some((s) => Math.abs(px - s) / s <= V46_TOLERANCE);
2965
+ const below = (px) => px < floor * (1 - V46_TOLERANCE);
2966
+ const findings = [];
2967
+ const seen = new Set(); // one finding per rule: a bundle repeats rules across @media copies
2968
+ const add = (f) => {
2969
+ const key = `${f.selector}|${f.value}|${f.issue}`;
2970
+ if (!seen.has(key)) {
2971
+ seen.add(key);
2972
+ findings.push(f);
2973
+ }
2974
+ };
2975
+ let judged = 0;
2976
+ let fluid = 0;
2977
+ const re = /font-size\s*:\s*([^;}]+)/gi;
2978
+ let m;
2979
+ while ((m = re.exec(css)) !== null) {
2980
+ const written = m[1].replace(/\/\*[\s\S]*?\*\//g, '').replace(/!important/i, '').trim();
2981
+ const value = resolveVar(written, tokens);
2982
+ if (/^var\(/i.test(value))
2983
+ continue; // an alias the token table cannot resolve
2984
+ if (/^(?:clamp|min|max)\(/i.test(value) && V46_FLUID.test(value)) {
2985
+ fluid++;
2986
+ const low = v46FluidFloor(value);
2987
+ if (low !== null && below(low)) {
2988
+ add({ selector: v46Selector(css, m.index), value: written, px: Math.round(low * 100) / 100, issue: 'fluid minimum below the floor', nearest: floor });
2989
+ }
2990
+ continue;
2991
+ }
2992
+ const px = v46StaticPx(value);
2993
+ if (px === null)
2994
+ continue; // em, %, keywords, calc(): relative or not static
2995
+ judged++;
2996
+ if (below(px)) {
2997
+ add({ selector: v46Selector(css, m.index), value: written, px: Math.round(px * 100) / 100, issue: 'below the floor', nearest: floor });
2998
+ }
2999
+ else if (!onScale(px)) {
3000
+ add({ selector: v46Selector(css, m.index), value: written, px: Math.round(px * 100) / 100, issue: 'off the scale', nearest: nearest(px) });
3001
+ }
3002
+ }
3003
+ const scaleText = `${steps.length}-step scale (ratio ${ratio.toFixed(3)}, ${scale.count} type tokens, floor ${floor}px)`;
3004
+ if (judged + fluid === 0) {
3005
+ return { id: 'v46', item: V46_ITEM, category: 'cadence', status: 'SKIP', detail: `no static or fluid font sizes outside the tokens to measure against the ${scaleText}` };
3006
+ }
3007
+ const offScale = findings.filter((f) => f.issue === 'off the scale').length;
3008
+ const underFloor = findings.length - offScale;
3009
+ if (findings.length === 0) {
3010
+ return { id: 'v46', item: V46_ITEM, category: 'cadence', status: 'PASS', detail: `${judged} static and ${fluid} fluid sizes sit on the ${scaleText}` };
3011
+ }
3012
+ return {
3013
+ id: 'v46', item: V46_ITEM, category: 'cadence', status: 'WARN',
3014
+ detail: `${offScale} static size(s) off the ${scaleText} and ${underFloor} below its floor, of ${judged} static and ${fluid} fluid sizes measured`,
3015
+ evidence: {
3016
+ findings: findings.slice(0, 20).map((f) => ({ selector: f.selector, value: f.value, px: f.px, issue: f.issue, nearest: f.nearest })),
3017
+ truncated: Math.max(0, findings.length - 20),
3018
+ },
3019
+ };
3020
+ }
2845
3021
  function checkPausedEntrances(css) {
2846
3022
  const ID = 'v45';
2847
3023
  const ITEM = 'Pausing motion keeps content visible';
@@ -3936,7 +4112,7 @@ function checkRemScale(css) {
3936
4112
  return { id: 'v16', item: 'Rem-based scale: all text sizes in rem, root at 16px confirmed', category: 'cadence', status: 'WARN', detail: `${pxMatches} px vs ${remMatches} rem` };
3937
4113
  }
3938
4114
  async function checkDesignMdSpec(targetUrl) {
3939
- const ITEM = 'DESIGN.md spec-layer validation (Google @google/design.md lint)';
4115
+ const ITEM = 'DESIGN.md spec-layer validation (DESIGN.md format lint)';
3940
4116
  const CATEGORY = 'spec';
3941
4117
  const parsed = new URL(targetUrl);
3942
4118
  const designMdUrl = `${parsed.protocol}//${parsed.host}/DESIGN.md`;
@@ -4340,6 +4516,7 @@ export async function scoreFromParts(input) {
4340
4516
  checkNoAllCaps(html),
4341
4517
  checkStatusTextContrast(css),
4342
4518
  checkPausedEntrances(css),
4519
+ checkTypeScale(css, rawTokens),
4343
4520
  ];
4344
4521
  // v37 is async — added after the synchronous checks array
4345
4522
  //
@@ -4351,7 +4528,7 @@ export async function scoreFromParts(input) {
4351
4528
  checks.push(input.offline
4352
4529
  ? {
4353
4530
  id: 'v37',
4354
- item: 'DESIGN.md spec-layer validation (Google @google/design.md lint)',
4531
+ item: 'DESIGN.md spec-layer validation (DESIGN.md format lint)',
4355
4532
  category: 'spec',
4356
4533
  status: 'SKIP',
4357
4534
  detail: 'not probed: offline fixture run (no network); excluded from the score',
@@ -4835,7 +5012,7 @@ export async function scoreFromParts(input) {
4835
5012
  * the site served v0.4.3; test/engine-1-2-0.test.mjs now reads the site's
4836
5013
  * contract source and fails when the two differ.
4837
5014
  */
4838
- export const CONTRACT_VERSION = 'v0.4.5';
5015
+ export const CONTRACT_VERSION = 'v0.4.6';
4839
5016
  /**
4840
5017
  * The one verdict rule, shared by every emission format.
4841
5018
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@designesy/score",
3
- "version": "0.8.1",
4
- "description": "Design quality gate for AI-generated UI: score any URL on 44 deterministic checks, A–F grade. Runs locally; browser checks return MANUAL. No required dependencies.",
3
+ "version": "0.9.1",
4
+ "description": "Design quality gate for AI-generated UI: score any URL on 45 deterministic checks, A–F grade. Runs locally; browser checks return MANUAL. No required dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Designesy <hello@designesy.org>",
7
7
  "homepage": "https://www.designesy.org/score",