@designesy/score 0.9.0 → 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.
Files changed (2) hide show
  1. package/dist/engine.js +48 -10
  2. package/package.json +1 -1
package/dist/engine.js CHANGED
@@ -485,7 +485,7 @@ const REMEDIATION = {
485
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.',
486
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).',
487
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.',
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.',
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.',
489
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.',
490
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.',
491
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.',
@@ -1139,6 +1139,39 @@ function isFieldSelector(selector) {
1139
1139
  || /\.(?:input|field)(?![\w-])/i.test(subject);
1140
1140
  });
1141
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
+ }
1142
1175
  /**
1143
1176
  * v27 helper (engine 1.3.1): in each rule whose selector styles a field
1144
1177
  * (isFieldSelector), every `font-size: var(...)` declaration is rewritten to
@@ -1160,12 +1193,9 @@ function resolveFontSizeVars(css, tokens) {
1160
1193
  if (css.indexOf('var(') < 0)
1161
1194
  return css;
1162
1195
  return css.replace(/(font-size\s*:\s*)(var\([^;{}]*\))/gi, (decl, lead, value, offset) => {
1163
- const open = css.lastIndexOf('{', offset);
1164
1196
  // 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)))
1197
+ const selector = ruleSelectorAt(css, offset);
1198
+ if (selector === null || !isFieldSelector(selector))
1169
1199
  return decl;
1170
1200
  const sub = substituteCssVars(value, [tokens]);
1171
1201
  return sub === null ? decl : lead + sub.trim();
@@ -1211,6 +1241,9 @@ function checkInputFontFloor(css, html, tokens = {}) {
1211
1241
  const below = [];
1212
1242
  let m;
1213
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;
1214
1247
  const val = parseFloat(m[1]);
1215
1248
  const unit = m[2].toLowerCase();
1216
1249
  const px = unit === 'rem' ? val * 16 : val;
@@ -1221,8 +1254,13 @@ function checkInputFontFloor(css, html, tokens = {}) {
1221
1254
  // Also check: is there a global input font-size ≥16px? (e.g. `input { font-size: 1rem }`)
1222
1255
  // The strict form `input\s*\{` matches standalone rules; the grouped form
1223
1256
  // `input,...,{` matches selectors merged by CSS minifiers (e.g. `input,textarea,select{font-size:1rem}`).
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);
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
+ }
1226
1264
  if (hasGlobalFloor)
1227
1265
  return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'PASS', detail: 'input font-size floor detected' };
1228
1266
  if (!hasZoomableField(html))
@@ -4074,7 +4112,7 @@ function checkRemScale(css) {
4074
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` };
4075
4113
  }
4076
4114
  async function checkDesignMdSpec(targetUrl) {
4077
- const ITEM = 'DESIGN.md spec-layer validation (Google @google/design.md lint)';
4115
+ const ITEM = 'DESIGN.md spec-layer validation (DESIGN.md format lint)';
4078
4116
  const CATEGORY = 'spec';
4079
4117
  const parsed = new URL(targetUrl);
4080
4118
  const designMdUrl = `${parsed.protocol}//${parsed.host}/DESIGN.md`;
@@ -4490,7 +4528,7 @@ export async function scoreFromParts(input) {
4490
4528
  checks.push(input.offline
4491
4529
  ? {
4492
4530
  id: 'v37',
4493
- item: 'DESIGN.md spec-layer validation (Google @google/design.md lint)',
4531
+ item: 'DESIGN.md spec-layer validation (DESIGN.md format lint)',
4494
4532
  category: 'spec',
4495
4533
  status: 'SKIP',
4496
4534
  detail: 'not probed: offline fixture run (no network); excluded from the score',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@designesy/score",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
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>",