@designesy/score 0.8.0 → 0.8.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
- 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 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.
4
4
 
5
5
  ## Install
6
6
 
package/dist/engine.d.ts CHANGED
@@ -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.5";
163
163
  /**
164
164
  * The one verdict rule, shared by every emission format.
165
165
  *
package/dist/engine.js CHANGED
@@ -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
  });
@@ -1086,7 +1101,13 @@ function checkFontFamilyCount(css, tokens) {
1086
1101
  // the adjusted @font-face. Those are not additional typeface choices — they
1087
1102
  // are the same family's fallback metrics — so they must not count as
1088
1103
  // palette drift.
1089
- if (/\sfallback$/.test(stack))
1104
+ //
1105
+ // Engine 1.3.1: the WORD fallback anywhere in the name, not only as its
1106
+ // last word. A hand-tuned face can carry a suffix after it: designesy.org's
1107
+ // "Fraunces Fallback Tuned" is Times New Roman under size-adjust, drawn only
1108
+ // before Fraunces loads, and `\sfallback$` counted it as a fourth family
1109
+ // (v26 WARN, "4 families: fraunces fallback tuned, fraunces, ...").
1110
+ if (/\bfallback\b/.test(stack))
1090
1111
  continue;
1091
1112
  families.add(stack);
1092
1113
  }
@@ -1098,6 +1119,57 @@ function checkFontFamilyCount(css, tokens) {
1098
1119
  return { id: 'v26', item: 'Font family count ≤3 (body + heading + mono)', category: 'cadence', status: 'WARN', detail: `${count} families (recommended ≤3): ${list}` };
1099
1120
  return { id: 'v26', item: 'Font family count ≤3 (body + heading + mono)', category: 'cadence', status: 'FAIL', detail: `${count} families, palette drift (recommended ≤3): ${list}` };
1100
1121
  }
1122
+ /**
1123
+ * v27 helper (engine 1.3.1): does this rule's selector style a form field
1124
+ * itself? True when the subject (the last compound) of any selector in the
1125
+ * list is an input, textarea or select element, or carries the exact class
1126
+ * .input or .field, and is not a pseudo-element (::placeholder does not set
1127
+ * the size iOS Safari reads). ".field-card-meta", ".input-hint", "::selection",
1128
+ * ".field label" and "user-select" are not fields.
1129
+ */
1130
+ function isFieldSelector(selector) {
1131
+ const clean = selector.replace(/\/\*[\s\S]*?\*\//g, ' ');
1132
+ return splitCssTopLevel(clean, ',').some((part) => {
1133
+ const compounds = part.trim().split(/\s*[>+~]\s*|\s+/);
1134
+ const subject = compounds[compounds.length - 1] || '';
1135
+ if (subject.indexOf('::') >= 0)
1136
+ return false;
1137
+ return /(?:^|[^\w.#:-])(?:input|textarea|select)(?![\w-])/i.test(subject)
1138
+ || /\.(?:input|field)(?![\w-])/i.test(subject);
1139
+ });
1140
+ }
1141
+ /**
1142
+ * v27 helper (engine 1.3.1): in each rule whose selector styles a field
1143
+ * (isFieldSelector), every `font-size: var(...)` declaration is rewritten to
1144
+ * its value from the :root token table, through substituteCssVars (var()
1145
+ * chains, and a var()'s own fallback where no token declares the name). A
1146
+ * value that does not resolve is left exactly as written, and so is every rule
1147
+ * that does not style a field: v27's literal scan matches its keywords
1148
+ * anywhere before a brace, so resolving var() in every rule would have read
1149
+ * designesy.org's `.field-card-meta { font-size: var(--text-label) }` (12.8px,
1150
+ * a card caption) as an input below 16px. Only values that start with var(
1151
+ * are touched; calc(), clamp() and min()/max() stay as written, as before.
1152
+ *
1153
+ * The scan is anchored on each `font-size: var(` and reads its rule's selector
1154
+ * backwards from the opening brace, so its cost is linear in the sheet. A
1155
+ * whole-sheet rule regex (`([^{}]*)\{([^{}]*)\}`) is quadratic on a long
1156
+ * @media prelude: measured at +4.5 s on five 43 KB preludes.
1157
+ */
1158
+ function resolveFontSizeVars(css, tokens) {
1159
+ if (css.indexOf('var(') < 0)
1160
+ return css;
1161
+ return css.replace(/(font-size\s*:\s*)(var\([^;{}]*\))/gi, (decl, lead, value, offset) => {
1162
+ const open = css.lastIndexOf('{', offset);
1163
+ // 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)))
1168
+ return decl;
1169
+ const sub = substituteCssVars(value, [tokens]);
1170
+ return sub === null ? decl : lead + sub.trim();
1171
+ });
1172
+ }
1101
1173
  /**
1102
1174
  * v27 helper: does the delivered markup carry a field iOS Safari zooms into on
1103
1175
  * focus? That is a <textarea>, a <select>, or an <input> of any type except the
@@ -1124,12 +1196,20 @@ function hasZoomableField(html) {
1124
1196
  // unchanged: a sub-16px input rule still FAILs and a declared floor still PASSes,
1125
1197
  // because both describe the stylesheet that fields rendered later by script
1126
1198
  // would also inherit.
1127
- function checkInputFontFloor(css, html) {
1199
+ //
1200
+ // Since engine 1.3.1 a font-size written as var() is read through the :root
1201
+ // token table first (resolveFontSizeVars), so `input { font-size:
1202
+ // var(--text-body) }` with `--text-body: 1rem` is the 16px floor it renders as.
1203
+ // Both regexes below read literals only; before 1.3.1 that rule read WARN ("no
1204
+ // explicit input font-size ≥16px detected") on designesy.org. A var() the table
1205
+ // cannot resolve is left as written, so it reads as it did before.
1206
+ function checkInputFontFloor(css, html, tokens = {}) {
1207
+ const sheet = resolveFontSizeVars(css, tokens);
1128
1208
  // Find input/textarea/select rules with font-size < 16px or < 1rem.
1129
1209
  const inputRe = /(?:input|textarea|select|\.input|\.field)[^{]*\{[^}]*font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gi;
1130
1210
  const below = [];
1131
1211
  let m;
1132
- while ((m = inputRe.exec(css)) !== null) {
1212
+ while ((m = inputRe.exec(sheet)) !== null) {
1133
1213
  const val = parseFloat(m[1]);
1134
1214
  const unit = m[2].toLowerCase();
1135
1215
  const px = unit === 'rem' ? val * 16 : val;
@@ -1140,8 +1220,8 @@ function checkInputFontFloor(css, html) {
1140
1220
  // Also check: is there a global input font-size ≥16px? (e.g. `input { font-size: 1rem }`)
1141
1221
  // The strict form `input\s*\{` matches standalone rules; the grouped form
1142
1222
  // `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);
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);
1145
1225
  if (hasGlobalFloor)
1146
1226
  return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'PASS', detail: 'input font-size floor detected' };
1147
1227
  if (!hasZoomableField(html))
@@ -1391,7 +1471,8 @@ function checkSemanticStatusRoles(tokens) {
1391
1471
  families.ok = true;
1392
1472
  if (segments.some((s) => ['warn', 'warning', 'caution'].includes(s)))
1393
1473
  families.warn = true;
1394
- if (segments.some((s) => ['error', 'danger', 'fail', 'critical', 'negative'].includes(s)))
1474
+ // 'destructive' is shadcn/ui's error role (--destructive); engine 1.3.1.
1475
+ if (segments.some((s) => ['error', 'danger', 'fail', 'critical', 'negative', 'destructive'].includes(s)))
1395
1476
  families.error = true;
1396
1477
  if (segments.some((s) => ['info', 'notice', 'informational'].includes(s)))
1397
1478
  families.info = true;
@@ -3343,6 +3424,25 @@ const RECOGNIZED_BUTTON_COMMANDS = new Set([
3343
3424
  'find', 'replace', 'navigate', 'run', 'score', 'review', 'verify',
3344
3425
  'detect', 'assess', 'enforce', 'dismiss', 'analyze', 'inspect', 'check',
3345
3426
  'evaluate', 'validate', 'test', 'monitor', 'track', 'measure', 'scan',
3427
+ // Engine 1.3.1. get, set and try: the comment in checkButtonTextVerb said
3428
+ // "Get X", "Set X" and "Try X" were allowed, but none of the three was in
3429
+ // this set, so "Get started" and "Try it free" read WARN on any site.
3430
+ 'get', 'set', 'try',
3431
+ // Imperative CTA verbs that the suffix heuristic in isVerbLike misses (it
3432
+ // already accepts explore, collapse, toggle, choose and compare by their -e
3433
+ // ending). Each is read as a verb at the start of a button label, and none
3434
+ // names a thing a button would be labelled with on its own:
3435
+ // flip "Flip card", "Flip to the flame": a command, no noun label use
3436
+ // learn "Learn more": verb only
3437
+ // sign "Sign in", "Sign up", "Sign out": the noun reading does not
3438
+ // lead a button label
3439
+ // join "Join the waitlist": verb only
3440
+ // expand "Expand all", "Expand section": verb only
3441
+ // select "Select all", "Select plan": an imperative in button use
3442
+ // read "Read more", "Read the guide": an imperative in button use
3443
+ // Left out on purpose: switch and watch, which are common nouns ("Switch",
3444
+ // a toggle's name; "Watch", a product) as well as verbs.
3445
+ 'flip', 'learn', 'sign', 'join', 'expand', 'select', 'read',
3346
3446
  ]);
3347
3447
  function isVerbLike(word) {
3348
3448
  const w = word.toLowerCase().trim();
@@ -3440,12 +3540,41 @@ function checkButtonTextVerb(html) {
3440
3540
  // with no space between). A space separates a real word: "Alt text" is a
3441
3541
  // label, not a shortcut, and must not be eaten.
3442
3542
  const TEXT_SHORTCUT_RE = /[\s]*(?:Ctrl|Cmd|Shift|Alt|Option|Command)(?:\+[A-Za-z0-9+\-]*|[A-Z0-9]+)?$/;
3543
+ // Engine 1.3.1: a "+key" hint written in lower case ("Save ctrl+s",
3544
+ // "Find ctrl+k") is a hint too. The modifier is case-insensitive here but
3545
+ // must stand as its own word (the start of the label or after a space), so
3546
+ // "Halt+1" is untouched, and only the plus form is read: a modifier followed
3547
+ // by a space and a letter ("Shift A") is a label, as it was in 1.3.0.
3548
+ const PLUS_SHORTCUT_RE = /(?:^|\s)(?:ctrl|cmd|shift|alt|option|command)(?:\s*\+\s*[a-z0-9]+)+$/i;
3549
+ const stripShortcut = (s) => s.replace(SHORTCUT_RE, '').replace(TEXT_SHORTCUT_RE, '').replace(PLUS_SHORTCUT_RE, '').trim();
3550
+ // Engine 1.3.1: keycaps are read from the markup, not guessed from text.
3551
+ // A <kbd> element is keyboard input (HTML Living Standard, the kbd element),
3552
+ // a hint beside the label rather than the label, so its text is dropped
3553
+ // before the verb check, innermost first so nested keycaps
3554
+ // (<kbd><kbd>Ctrl</kbd>+<kbd>K</kbd></kbd>) go too. designesy.org's flip
3555
+ // button, <kbd>ctrl</kbd> <span aria-hidden="true">+</span> <kbd>i</kbd>
3556
+ // after a hidden lead, read as "Flip to the flame: ctrl i" in 1.3.0. A "+"
3557
+ // left between dropped keycaps is dropped with them. A button whose only
3558
+ // text is keycaps is then empty and is judged as an icon-only button: by its
3559
+ // aria-label when it has one, and not at all when it does not.
3560
+ const labelText = (inner) => {
3561
+ const visible = stripAriaHidden(inner);
3562
+ let out = visible;
3563
+ for (let i = 0; i < 4; i++) {
3564
+ const next = out.replace(/<kbd\b[^>]*>(?:(?!<kbd\b)[\s\S])*?<\/kbd>/gi, ' ');
3565
+ if (next === out)
3566
+ break;
3567
+ out = next;
3568
+ }
3569
+ const text = out.replace(/<[^>]*>/g, '').trim();
3570
+ return out === visible ? text : text.replace(/(?:\s*\+)+$/, '').trim();
3571
+ };
3443
3572
  while ((m = buttonRe.exec(html)) !== null) {
3444
- let text = stripAriaHidden(m[1]).replace(/<[^>]*>/g, '').trim();
3573
+ let text = labelText(m[1]);
3445
3574
  // Strip leading icon characters so "✕Close" → "Close"
3446
3575
  text = text.replace(ICON_PREFIX_RE, '').trim();
3447
3576
  // Strip trailing keyboard shortcut hints so "Find⌘K" → "Find"
3448
- text = text.replace(SHORTCUT_RE, '').replace(TEXT_SHORTCUT_RE, '').trim();
3577
+ text = stripShortcut(text);
3449
3578
  // Icon-only button with an accessible name: use the aria-label verb.
3450
3579
  if (!text) {
3451
3580
  const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
@@ -3466,9 +3595,9 @@ function checkButtonTextVerb(html) {
3466
3595
  buttonTexts.push({ text, tag: m[0] });
3467
3596
  }
3468
3597
  while ((m = roleButtonRe.exec(html)) !== null) {
3469
- let text = stripAriaHidden(m[1]).replace(/<[^>]*>/g, '').trim();
3598
+ let text = labelText(m[1]);
3470
3599
  text = text.replace(ICON_PREFIX_RE, '').trim();
3471
- text = text.replace(SHORTCUT_RE, '').replace(TEXT_SHORTCUT_RE, '').trim();
3600
+ text = stripShortcut(text);
3472
3601
  if (!text) {
3473
3602
  const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
3474
3603
  if (aria)
@@ -3530,7 +3659,9 @@ function checkButtonTextVerb(html) {
3530
3659
  // Allow if first word is verb-like
3531
3660
  if (isVerbLike(firstWord))
3532
3661
  continue;
3533
- // Allow "Get X", "Set X", "Try X" patterns (Get/Set/Try are in the set)
3662
+ // "Get X", "Set X", "Try X" are allowed above: get, set and try are in
3663
+ // RECOGNIZED_BUTTON_COMMANDS (since engine 1.3.1; this comment claimed it
3664
+ // before they were).
3534
3665
  // If we get here, the first word is likely a noun → potential violation
3535
3666
  violations.push(`"${text}"`);
3536
3667
  }
@@ -4195,7 +4326,7 @@ export async function scoreFromParts(input) {
4195
4326
  checkTouchTargets(css),
4196
4327
  checkHeadingHierarchy(html),
4197
4328
  checkFontFamilyCount(css, tokens),
4198
- checkInputFontFloor(css, html),
4329
+ checkInputFontFloor(css, html, rawTokens),
4199
4330
  checkReadingWidth(css),
4200
4331
  checkTokenLayerDepth(tokens),
4201
4332
  checkSemanticColorVocabulary(tokens),
@@ -4704,7 +4835,7 @@ export async function scoreFromParts(input) {
4704
4835
  * the site served v0.4.3; test/engine-1-2-0.test.mjs now reads the site's
4705
4836
  * contract source and fails when the two differ.
4706
4837
  */
4707
- export const CONTRACT_VERSION = 'v0.4.4';
4838
+ export const CONTRACT_VERSION = 'v0.4.5';
4708
4839
  /**
4709
4840
  * The one verdict rule, shared by every emission format.
4710
4841
  *
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.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.",
5
5
  "license": "MIT",
6
6
  "author": "Designesy <hello@designesy.org>",
7
7
  "homepage": "https://www.designesy.org/score",