@designesy/score 0.8.0 → 0.9.0

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
- Standalone 44-check design-contract scoring engine. Fetches a URL, extracts CSS + `:root` tokens, runs all checks locally, with **no server required**. 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.4";
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
  *
@@ -209,7 +209,21 @@ const BROWSER_HEADERS = {
209
209
  'Sec-Fetch-User': '?1',
210
210
  'Upgrade-Insecure-Requests': '1',
211
211
  };
212
- function httpsFetch(url, headers, timeoutMs = 8000) {
212
+ /** Redirects httpsFetch follows before it gives up (engine 1.3.1). */
213
+ const MAX_REDIRECT_HOPS = 3;
214
+ /**
215
+ * GET a URL, following each 3xx with a Location by hand. Every redirect target
216
+ * is re-validated with isValidUrl first (the SSRF guard), and at most
217
+ * MAX_REDIRECT_HOPS redirects are followed. Until engine 1.3.1 the comment said
218
+ * "Follow up to 3 redirects" but nothing counted them, so a redirect loop
219
+ * recursed until the process gave out. A redirect past the limit fails closed,
220
+ * the same way a refused target does. A response reached through at least one
221
+ * redirect reports redirected: true and the final hop's URL as finalUrl; before
222
+ * 1.3.1 redirected was true only when the LAST response was itself a 3xx, so a
223
+ * followed redirect read redirected: false. `hop` counts the redirects already
224
+ * followed; callers leave it at 0.
225
+ */
226
+ function httpsFetch(url, headers, timeoutMs = 8000, hop = 0) {
213
227
  return new Promise((resolve) => {
214
228
  let resolved = false;
215
229
  const done = (r) => {
@@ -220,18 +234,19 @@ function httpsFetch(url, headers, timeoutMs = 8000) {
220
234
  };
221
235
  try {
222
236
  const req = httpsGet(url, { headers: { ...BROWSER_HEADERS, ...(headers || {}) }, timeout: timeoutMs, lookup: safeLookup }, (res) => {
223
- // Follow one redirect manually (3xx → Location)
237
+ // Follow a redirect manually (3xx → Location)
224
238
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
225
239
  try {
226
240
  const next = new URL(res.headers.location, url).href;
227
241
  res.resume();
228
- // SSRF guard: re-validate every redirect target before following it
229
- if (!isValidUrl(next)) {
242
+ // SSRF guard: re-validate every redirect target before following
243
+ // it. Hop limit: a chain longer than MAX_REDIRECT_HOPS (a loop
244
+ // among them) fails closed the same way.
245
+ if (!isValidUrl(next) || hop >= MAX_REDIRECT_HOPS) {
230
246
  done({ ok: false, status: 0, text: '', redirected: false, finalUrl: url });
231
247
  return;
232
248
  }
233
- // Follow up to 3 redirects
234
- httpsFetch(next, headers, timeoutMs).then(done);
249
+ httpsFetch(next, headers, timeoutMs, hop + 1).then(done);
235
250
  return;
236
251
  }
237
252
  catch { /* fall through */ }
@@ -240,7 +255,7 @@ function httpsFetch(url, headers, timeoutMs = 8000) {
240
255
  res.setEncoding('utf-8');
241
256
  res.on('data', (chunk) => { body += chunk; });
242
257
  res.on('end', () => {
243
- done({ ok: (res.statusCode || 0) < 400, status: res.statusCode || 0, text: body, redirected: res.statusCode !== undefined && res.statusCode >= 300 && res.statusCode < 400, finalUrl: url });
258
+ done({ ok: (res.statusCode || 0) < 400, status: res.statusCode || 0, text: body, redirected: hop > 0, finalUrl: url });
244
259
  });
245
260
  res.on('error', () => done({ ok: false, status: 0, text: '', redirected: false, finalUrl: url }));
246
261
  });
@@ -466,10 +481,11 @@ const REMEDIATION = {
466
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.',
467
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.',
468
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.',
469
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.',
470
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).',
471
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.',
472
- 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. 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 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.',
473
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.',
474
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.',
475
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.',
@@ -1086,7 +1102,13 @@ function checkFontFamilyCount(css, tokens) {
1086
1102
  // the adjusted @font-face. Those are not additional typeface choices — they
1087
1103
  // are the same family's fallback metrics — so they must not count as
1088
1104
  // palette drift.
1089
- if (/\sfallback$/.test(stack))
1105
+ //
1106
+ // Engine 1.3.1: the WORD fallback anywhere in the name, not only as its
1107
+ // last word. A hand-tuned face can carry a suffix after it: designesy.org's
1108
+ // "Fraunces Fallback Tuned" is Times New Roman under size-adjust, drawn only
1109
+ // before Fraunces loads, and `\sfallback$` counted it as a fourth family
1110
+ // (v26 WARN, "4 families: fraunces fallback tuned, fraunces, ...").
1111
+ if (/\bfallback\b/.test(stack))
1090
1112
  continue;
1091
1113
  families.add(stack);
1092
1114
  }
@@ -1098,6 +1120,57 @@ function checkFontFamilyCount(css, tokens) {
1098
1120
  return { id: 'v26', item: 'Font family count ≤3 (body + heading + mono)', category: 'cadence', status: 'WARN', detail: `${count} families (recommended ≤3): ${list}` };
1099
1121
  return { id: 'v26', item: 'Font family count ≤3 (body + heading + mono)', category: 'cadence', status: 'FAIL', detail: `${count} families, palette drift (recommended ≤3): ${list}` };
1100
1122
  }
1123
+ /**
1124
+ * v27 helper (engine 1.3.1): does this rule's selector style a form field
1125
+ * itself? True when the subject (the last compound) of any selector in the
1126
+ * list is an input, textarea or select element, or carries the exact class
1127
+ * .input or .field, and is not a pseudo-element (::placeholder does not set
1128
+ * the size iOS Safari reads). ".field-card-meta", ".input-hint", "::selection",
1129
+ * ".field label" and "user-select" are not fields.
1130
+ */
1131
+ function isFieldSelector(selector) {
1132
+ const clean = selector.replace(/\/\*[\s\S]*?\*\//g, ' ');
1133
+ return splitCssTopLevel(clean, ',').some((part) => {
1134
+ const compounds = part.trim().split(/\s*[>+~]\s*|\s+/);
1135
+ const subject = compounds[compounds.length - 1] || '';
1136
+ if (subject.indexOf('::') >= 0)
1137
+ return false;
1138
+ return /(?:^|[^\w.#:-])(?:input|textarea|select)(?![\w-])/i.test(subject)
1139
+ || /\.(?:input|field)(?![\w-])/i.test(subject);
1140
+ });
1141
+ }
1142
+ /**
1143
+ * v27 helper (engine 1.3.1): in each rule whose selector styles a field
1144
+ * (isFieldSelector), every `font-size: var(...)` declaration is rewritten to
1145
+ * its value from the :root token table, through substituteCssVars (var()
1146
+ * chains, and a var()'s own fallback where no token declares the name). A
1147
+ * value that does not resolve is left exactly as written, and so is every rule
1148
+ * that does not style a field: v27's literal scan matches its keywords
1149
+ * anywhere before a brace, so resolving var() in every rule would have read
1150
+ * designesy.org's `.field-card-meta { font-size: var(--text-label) }` (12.8px,
1151
+ * a card caption) as an input below 16px. Only values that start with var(
1152
+ * are touched; calc(), clamp() and min()/max() stay as written, as before.
1153
+ *
1154
+ * The scan is anchored on each `font-size: var(` and reads its rule's selector
1155
+ * backwards from the opening brace, so its cost is linear in the sheet. A
1156
+ * whole-sheet rule regex (`([^{}]*)\{([^{}]*)\}`) is quadratic on a long
1157
+ * @media prelude: measured at +4.5 s on five 43 KB preludes.
1158
+ */
1159
+ function resolveFontSizeVars(css, tokens) {
1160
+ if (css.indexOf('var(') < 0)
1161
+ return css;
1162
+ return css.replace(/(font-size\s*:\s*)(var\([^;{}]*\))/gi, (decl, lead, value, offset) => {
1163
+ const open = css.lastIndexOf('{', offset);
1164
+ // Not inside a rule body (a stray declaration, or a brace closed between).
1165
+ if (open < 0 || css.lastIndexOf('}', offset) > open)
1166
+ return decl;
1167
+ const start = Math.max(css.lastIndexOf('}', open - 1), css.lastIndexOf('{', open - 1), css.lastIndexOf(';', open - 1)) + 1;
1168
+ if (!isFieldSelector(css.slice(start, open)))
1169
+ return decl;
1170
+ const sub = substituteCssVars(value, [tokens]);
1171
+ return sub === null ? decl : lead + sub.trim();
1172
+ });
1173
+ }
1101
1174
  /**
1102
1175
  * v27 helper: does the delivered markup carry a field iOS Safari zooms into on
1103
1176
  * focus? That is a <textarea>, a <select>, or an <input> of any type except the
@@ -1124,12 +1197,20 @@ function hasZoomableField(html) {
1124
1197
  // unchanged: a sub-16px input rule still FAILs and a declared floor still PASSes,
1125
1198
  // because both describe the stylesheet that fields rendered later by script
1126
1199
  // would also inherit.
1127
- function checkInputFontFloor(css, html) {
1200
+ //
1201
+ // Since engine 1.3.1 a font-size written as var() is read through the :root
1202
+ // token table first (resolveFontSizeVars), so `input { font-size:
1203
+ // var(--text-body) }` with `--text-body: 1rem` is the 16px floor it renders as.
1204
+ // Both regexes below read literals only; before 1.3.1 that rule read WARN ("no
1205
+ // explicit input font-size ≥16px detected") on designesy.org. A var() the table
1206
+ // cannot resolve is left as written, so it reads as it did before.
1207
+ function checkInputFontFloor(css, html, tokens = {}) {
1208
+ const sheet = resolveFontSizeVars(css, tokens);
1128
1209
  // Find input/textarea/select rules with font-size < 16px or < 1rem.
1129
1210
  const inputRe = /(?:input|textarea|select|\.input|\.field)[^{]*\{[^}]*font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gi;
1130
1211
  const below = [];
1131
1212
  let m;
1132
- while ((m = inputRe.exec(css)) !== null) {
1213
+ while ((m = inputRe.exec(sheet)) !== null) {
1133
1214
  const val = parseFloat(m[1]);
1134
1215
  const unit = m[2].toLowerCase();
1135
1216
  const px = unit === 'rem' ? val * 16 : val;
@@ -1140,8 +1221,8 @@ function checkInputFontFloor(css, html) {
1140
1221
  // Also check: is there a global input font-size ≥16px? (e.g. `input { font-size: 1rem }`)
1141
1222
  // The strict form `input\s*\{` matches standalone rules; the grouped form
1142
1223
  // `input,...,{` matches selectors merged by CSS minifiers (e.g. `input,textarea,select{font-size:1rem}`).
1143
- const hasGlobalFloor = /input\s*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(css)
1144
- || /input\s*[,][^{]*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(css);
1224
+ const hasGlobalFloor = /input\s*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(sheet)
1225
+ || /input\s*[,][^{]*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(sheet);
1145
1226
  if (hasGlobalFloor)
1146
1227
  return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'PASS', detail: 'input font-size floor detected' };
1147
1228
  if (!hasZoomableField(html))
@@ -1391,7 +1472,8 @@ function checkSemanticStatusRoles(tokens) {
1391
1472
  families.ok = true;
1392
1473
  if (segments.some((s) => ['warn', 'warning', 'caution'].includes(s)))
1393
1474
  families.warn = true;
1394
- if (segments.some((s) => ['error', 'danger', 'fail', 'critical', 'negative'].includes(s)))
1475
+ // 'destructive' is shadcn/ui's error role (--destructive); engine 1.3.1.
1476
+ if (segments.some((s) => ['error', 'danger', 'fail', 'critical', 'negative', 'destructive'].includes(s)))
1395
1477
  families.error = true;
1396
1478
  if (segments.some((s) => ['info', 'notice', 'informational'].includes(s)))
1397
1479
  families.info = true;
@@ -2761,6 +2843,143 @@ function isDocumentScope(compound) {
2761
2843
  return simple.every((s) => s.startsWith('.') || s.startsWith('['))
2762
2844
  && simple.some((s) => V45_TOGGLE_ATTR.test(s) || V45_TOGGLE_CLASS.test(s));
2763
2845
  }
2846
+ // v46 — Type sizes come from the scale (engine 1.4.0).
2847
+ //
2848
+ // v16 asks that sizes be written in rem; it cannot tell a rem that sits on the
2849
+ // type scale from one that sits between steps, so a page can pass v16 while its
2850
+ // sizes drift off the scale its own tokens declare. v46 reads the scale from the
2851
+ // page itself: the :root type-size tokens (a name such as --text-*, --font-size-*,
2852
+ // --fs-* or --type-*, whose value resolves to a static rem or px length; colour
2853
+ // tokens named --text-muted never resolve to a length, so they never count). The
2854
+ // steps are every token value plus the token ratio (the median of consecutive
2855
+ // token ratios) extended four steps below and ten above the token nearest 16px.
2856
+ // The floor is the smallest type token.
2857
+ //
2858
+ // Fluid type is a deliberate design move, so a clamp(), min() or max() that
2859
+ // moves with a viewport or container unit is accepted as written; only its lower
2860
+ // bound is held to the floor. Relative sizes (em, %, inherit, keywords) follow
2861
+ // their parent, not the global scale, and are not judged. A static rem or px
2862
+ // size is judged: off the scale when it sits more than 2.5% from every step,
2863
+ // below the floor when it is smaller than the smallest type token.
2864
+ //
2865
+ // WARN only: an off-scale size is a hygiene finding, not a broken page.
2866
+ const V46_ITEM = 'Type sizes come from the scale';
2867
+ const V46_TOLERANCE = 0.025;
2868
+ const V46_FLUID = /(?:^|[^a-z-])(?:[sld]?v[wh]|v[ib]|vmin|vmax|cq[iwhb]|cqmin|cqmax)\b/i;
2869
+ function v46StaticPx(value) {
2870
+ const m = value.trim().match(/^(\d*\.?\d+)(rem|px)$/i);
2871
+ if (!m)
2872
+ return null;
2873
+ return parseFloat(m[1]) * (m[2].toLowerCase() === 'rem' ? 16 : 1);
2874
+ }
2875
+ function v46TypeScale(tokens) {
2876
+ const values = [];
2877
+ for (const [name, raw] of Object.entries(tokens)) {
2878
+ if (!/^--(?:text|font-size|fs|type)-[\w-]+$/i.test(name))
2879
+ continue;
2880
+ const px = v46StaticPx(resolveVar(raw, tokens));
2881
+ if (px !== null && px > 0)
2882
+ values.push(px);
2883
+ }
2884
+ const unique = [...new Set(values.map((v) => Math.round(v * 1000) / 1000))].sort((a, b) => a - b);
2885
+ if (unique.length < 3)
2886
+ return null;
2887
+ const ratios = [];
2888
+ for (let i = 1; i < unique.length; i++) {
2889
+ const r = unique[i] / unique[i - 1];
2890
+ if (r >= 1.05 && r <= 1.7)
2891
+ ratios.push(r);
2892
+ }
2893
+ ratios.sort((a, b) => a - b);
2894
+ const ratio = ratios.length ? ratios[Math.floor(ratios.length / 2)] : 1.25;
2895
+ const base = unique.reduce((best, v) => (Math.abs(v - 16) < Math.abs(best - 16) ? v : best), unique[0]);
2896
+ const steps = new Set(unique);
2897
+ for (let n = -4; n <= 10; n++)
2898
+ steps.add(Math.round(base * Math.pow(ratio, n) * 1000) / 1000);
2899
+ return { steps: [...steps].sort((a, b) => a - b), floor: unique[0], ratio, count: unique.length };
2900
+ }
2901
+ /** Lower bound in px of a fluid size: clamp()'s first argument, or the largest static argument of max(). */
2902
+ function v46FluidFloor(value) {
2903
+ const fn = value.trim().match(/^(clamp|max)\((.*)\)$/i);
2904
+ if (!fn)
2905
+ return null;
2906
+ const args = splitCssTopLevel(fn[2], ',').map((a) => v46StaticPx(a));
2907
+ if (fn[1].toLowerCase() === 'clamp')
2908
+ return args.length === 3 ? args[0] : null;
2909
+ const statics = args.filter((a) => a !== null);
2910
+ return statics.length ? Math.max(...statics) : null;
2911
+ }
2912
+ function v46Selector(css, at) {
2913
+ const open = css.lastIndexOf('{', at);
2914
+ if (open < 0)
2915
+ return '';
2916
+ const start = Math.max(css.lastIndexOf('}', open), css.lastIndexOf(';', open), css.lastIndexOf('{', open - 1)) + 1; // stop at an enclosing @media brace too
2917
+ return css.slice(start, open).replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
2918
+ }
2919
+ function checkTypeScale(css, tokens) {
2920
+ const scale = v46TypeScale(tokens);
2921
+ if (!scale) {
2922
+ return { id: 'v46', item: V46_ITEM, category: 'cadence', status: 'SKIP', detail: 'fewer than 3 type-size tokens in :root: no scale to measure against' };
2923
+ }
2924
+ const { steps, floor, ratio } = scale;
2925
+ const nearest = (px) => steps.reduce((best, s) => (Math.abs(s - px) < Math.abs(best - px) ? s : best), steps[0]);
2926
+ const onScale = (px) => steps.some((s) => Math.abs(px - s) / s <= V46_TOLERANCE);
2927
+ const below = (px) => px < floor * (1 - V46_TOLERANCE);
2928
+ const findings = [];
2929
+ const seen = new Set(); // one finding per rule: a bundle repeats rules across @media copies
2930
+ const add = (f) => {
2931
+ const key = `${f.selector}|${f.value}|${f.issue}`;
2932
+ if (!seen.has(key)) {
2933
+ seen.add(key);
2934
+ findings.push(f);
2935
+ }
2936
+ };
2937
+ let judged = 0;
2938
+ let fluid = 0;
2939
+ const re = /font-size\s*:\s*([^;}]+)/gi;
2940
+ let m;
2941
+ while ((m = re.exec(css)) !== null) {
2942
+ const written = m[1].replace(/\/\*[\s\S]*?\*\//g, '').replace(/!important/i, '').trim();
2943
+ const value = resolveVar(written, tokens);
2944
+ if (/^var\(/i.test(value))
2945
+ continue; // an alias the token table cannot resolve
2946
+ if (/^(?:clamp|min|max)\(/i.test(value) && V46_FLUID.test(value)) {
2947
+ fluid++;
2948
+ const low = v46FluidFloor(value);
2949
+ if (low !== null && below(low)) {
2950
+ add({ selector: v46Selector(css, m.index), value: written, px: Math.round(low * 100) / 100, issue: 'fluid minimum below the floor', nearest: floor });
2951
+ }
2952
+ continue;
2953
+ }
2954
+ const px = v46StaticPx(value);
2955
+ if (px === null)
2956
+ continue; // em, %, keywords, calc(): relative or not static
2957
+ judged++;
2958
+ if (below(px)) {
2959
+ add({ selector: v46Selector(css, m.index), value: written, px: Math.round(px * 100) / 100, issue: 'below the floor', nearest: floor });
2960
+ }
2961
+ else if (!onScale(px)) {
2962
+ add({ selector: v46Selector(css, m.index), value: written, px: Math.round(px * 100) / 100, issue: 'off the scale', nearest: nearest(px) });
2963
+ }
2964
+ }
2965
+ const scaleText = `${steps.length}-step scale (ratio ${ratio.toFixed(3)}, ${scale.count} type tokens, floor ${floor}px)`;
2966
+ if (judged + fluid === 0) {
2967
+ 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}` };
2968
+ }
2969
+ const offScale = findings.filter((f) => f.issue === 'off the scale').length;
2970
+ const underFloor = findings.length - offScale;
2971
+ if (findings.length === 0) {
2972
+ return { id: 'v46', item: V46_ITEM, category: 'cadence', status: 'PASS', detail: `${judged} static and ${fluid} fluid sizes sit on the ${scaleText}` };
2973
+ }
2974
+ return {
2975
+ id: 'v46', item: V46_ITEM, category: 'cadence', status: 'WARN',
2976
+ detail: `${offScale} static size(s) off the ${scaleText} and ${underFloor} below its floor, of ${judged} static and ${fluid} fluid sizes measured`,
2977
+ evidence: {
2978
+ findings: findings.slice(0, 20).map((f) => ({ selector: f.selector, value: f.value, px: f.px, issue: f.issue, nearest: f.nearest })),
2979
+ truncated: Math.max(0, findings.length - 20),
2980
+ },
2981
+ };
2982
+ }
2764
2983
  function checkPausedEntrances(css) {
2765
2984
  const ID = 'v45';
2766
2985
  const ITEM = 'Pausing motion keeps content visible';
@@ -3343,6 +3562,25 @@ const RECOGNIZED_BUTTON_COMMANDS = new Set([
3343
3562
  'find', 'replace', 'navigate', 'run', 'score', 'review', 'verify',
3344
3563
  'detect', 'assess', 'enforce', 'dismiss', 'analyze', 'inspect', 'check',
3345
3564
  'evaluate', 'validate', 'test', 'monitor', 'track', 'measure', 'scan',
3565
+ // Engine 1.3.1. get, set and try: the comment in checkButtonTextVerb said
3566
+ // "Get X", "Set X" and "Try X" were allowed, but none of the three was in
3567
+ // this set, so "Get started" and "Try it free" read WARN on any site.
3568
+ 'get', 'set', 'try',
3569
+ // Imperative CTA verbs that the suffix heuristic in isVerbLike misses (it
3570
+ // already accepts explore, collapse, toggle, choose and compare by their -e
3571
+ // ending). Each is read as a verb at the start of a button label, and none
3572
+ // names a thing a button would be labelled with on its own:
3573
+ // flip "Flip card", "Flip to the flame": a command, no noun label use
3574
+ // learn "Learn more": verb only
3575
+ // sign "Sign in", "Sign up", "Sign out": the noun reading does not
3576
+ // lead a button label
3577
+ // join "Join the waitlist": verb only
3578
+ // expand "Expand all", "Expand section": verb only
3579
+ // select "Select all", "Select plan": an imperative in button use
3580
+ // read "Read more", "Read the guide": an imperative in button use
3581
+ // Left out on purpose: switch and watch, which are common nouns ("Switch",
3582
+ // a toggle's name; "Watch", a product) as well as verbs.
3583
+ 'flip', 'learn', 'sign', 'join', 'expand', 'select', 'read',
3346
3584
  ]);
3347
3585
  function isVerbLike(word) {
3348
3586
  const w = word.toLowerCase().trim();
@@ -3440,12 +3678,41 @@ function checkButtonTextVerb(html) {
3440
3678
  // with no space between). A space separates a real word: "Alt text" is a
3441
3679
  // label, not a shortcut, and must not be eaten.
3442
3680
  const TEXT_SHORTCUT_RE = /[\s]*(?:Ctrl|Cmd|Shift|Alt|Option|Command)(?:\+[A-Za-z0-9+\-]*|[A-Z0-9]+)?$/;
3681
+ // Engine 1.3.1: a "+key" hint written in lower case ("Save ctrl+s",
3682
+ // "Find ctrl+k") is a hint too. The modifier is case-insensitive here but
3683
+ // must stand as its own word (the start of the label or after a space), so
3684
+ // "Halt+1" is untouched, and only the plus form is read: a modifier followed
3685
+ // by a space and a letter ("Shift A") is a label, as it was in 1.3.0.
3686
+ const PLUS_SHORTCUT_RE = /(?:^|\s)(?:ctrl|cmd|shift|alt|option|command)(?:\s*\+\s*[a-z0-9]+)+$/i;
3687
+ const stripShortcut = (s) => s.replace(SHORTCUT_RE, '').replace(TEXT_SHORTCUT_RE, '').replace(PLUS_SHORTCUT_RE, '').trim();
3688
+ // Engine 1.3.1: keycaps are read from the markup, not guessed from text.
3689
+ // A <kbd> element is keyboard input (HTML Living Standard, the kbd element),
3690
+ // a hint beside the label rather than the label, so its text is dropped
3691
+ // before the verb check, innermost first so nested keycaps
3692
+ // (<kbd><kbd>Ctrl</kbd>+<kbd>K</kbd></kbd>) go too. designesy.org's flip
3693
+ // button, <kbd>ctrl</kbd> <span aria-hidden="true">+</span> <kbd>i</kbd>
3694
+ // after a hidden lead, read as "Flip to the flame: ctrl i" in 1.3.0. A "+"
3695
+ // left between dropped keycaps is dropped with them. A button whose only
3696
+ // text is keycaps is then empty and is judged as an icon-only button: by its
3697
+ // aria-label when it has one, and not at all when it does not.
3698
+ const labelText = (inner) => {
3699
+ const visible = stripAriaHidden(inner);
3700
+ let out = visible;
3701
+ for (let i = 0; i < 4; i++) {
3702
+ const next = out.replace(/<kbd\b[^>]*>(?:(?!<kbd\b)[\s\S])*?<\/kbd>/gi, ' ');
3703
+ if (next === out)
3704
+ break;
3705
+ out = next;
3706
+ }
3707
+ const text = out.replace(/<[^>]*>/g, '').trim();
3708
+ return out === visible ? text : text.replace(/(?:\s*\+)+$/, '').trim();
3709
+ };
3443
3710
  while ((m = buttonRe.exec(html)) !== null) {
3444
- let text = stripAriaHidden(m[1]).replace(/<[^>]*>/g, '').trim();
3711
+ let text = labelText(m[1]);
3445
3712
  // Strip leading icon characters so "✕Close" → "Close"
3446
3713
  text = text.replace(ICON_PREFIX_RE, '').trim();
3447
3714
  // Strip trailing keyboard shortcut hints so "Find⌘K" → "Find"
3448
- text = text.replace(SHORTCUT_RE, '').replace(TEXT_SHORTCUT_RE, '').trim();
3715
+ text = stripShortcut(text);
3449
3716
  // Icon-only button with an accessible name: use the aria-label verb.
3450
3717
  if (!text) {
3451
3718
  const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
@@ -3466,9 +3733,9 @@ function checkButtonTextVerb(html) {
3466
3733
  buttonTexts.push({ text, tag: m[0] });
3467
3734
  }
3468
3735
  while ((m = roleButtonRe.exec(html)) !== null) {
3469
- let text = stripAriaHidden(m[1]).replace(/<[^>]*>/g, '').trim();
3736
+ let text = labelText(m[1]);
3470
3737
  text = text.replace(ICON_PREFIX_RE, '').trim();
3471
- text = text.replace(SHORTCUT_RE, '').replace(TEXT_SHORTCUT_RE, '').trim();
3738
+ text = stripShortcut(text);
3472
3739
  if (!text) {
3473
3740
  const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
3474
3741
  if (aria)
@@ -3530,7 +3797,9 @@ function checkButtonTextVerb(html) {
3530
3797
  // Allow if first word is verb-like
3531
3798
  if (isVerbLike(firstWord))
3532
3799
  continue;
3533
- // Allow "Get X", "Set X", "Try X" patterns (Get/Set/Try are in the set)
3800
+ // "Get X", "Set X", "Try X" are allowed above: get, set and try are in
3801
+ // RECOGNIZED_BUTTON_COMMANDS (since engine 1.3.1; this comment claimed it
3802
+ // before they were).
3534
3803
  // If we get here, the first word is likely a noun → potential violation
3535
3804
  violations.push(`"${text}"`);
3536
3805
  }
@@ -4195,7 +4464,7 @@ export async function scoreFromParts(input) {
4195
4464
  checkTouchTargets(css),
4196
4465
  checkHeadingHierarchy(html),
4197
4466
  checkFontFamilyCount(css, tokens),
4198
- checkInputFontFloor(css, html),
4467
+ checkInputFontFloor(css, html, rawTokens),
4199
4468
  checkReadingWidth(css),
4200
4469
  checkTokenLayerDepth(tokens),
4201
4470
  checkSemanticColorVocabulary(tokens),
@@ -4209,6 +4478,7 @@ export async function scoreFromParts(input) {
4209
4478
  checkNoAllCaps(html),
4210
4479
  checkStatusTextContrast(css),
4211
4480
  checkPausedEntrances(css),
4481
+ checkTypeScale(css, rawTokens),
4212
4482
  ];
4213
4483
  // v37 is async — added after the synchronous checks array
4214
4484
  //
@@ -4704,7 +4974,7 @@ export async function scoreFromParts(input) {
4704
4974
  * the site served v0.4.3; test/engine-1-2-0.test.mjs now reads the site's
4705
4975
  * contract source and fails when the two differ.
4706
4976
  */
4707
- export const CONTRACT_VERSION = 'v0.4.4';
4977
+ export const CONTRACT_VERSION = 'v0.4.6';
4708
4978
  /**
4709
4979
  * The one verdict rule, shared by every emission format.
4710
4980
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@designesy/score",
3
- "version": "0.8.0",
4
- "description": "Standalone 44-check design-contract scoring engine: fetches a URL, extracts CSS + tokens, and runs all checks locally. No required dependencies.",
3
+ "version": "0.9.0",
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",