@designesy/score 0.5.0 → 0.6.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.
Files changed (4) hide show
  1. package/README.md +110 -110
  2. package/dist/cli.js +28 -26
  3. package/dist/engine.js +92 -15
  4. package/package.json +55 -55
package/README.md CHANGED
@@ -1,111 +1,111 @@
1
- # @designesy/score
2
-
3
- Standalone 42-check design-contract scoring engine. Fetches a URL, extracts CSS + `:root` tokens, runs all checks locally, with **no server required**. Zero dependencies.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install --save-dev @designesy/score
9
- # or
10
- npx @designesy/score <url>
11
- ```
12
-
13
- ## CLI
14
-
15
- ```bash
16
- # Score a site (prints formatted report)
17
- npx @designesy/score designesy.org
18
-
19
- # CI gate: fail if below threshold
20
- npx @designesy/score linear.app --min-score 70 --min-grade B
21
-
22
- # JSON output for pipelines
23
- npx @designesy/score vercel.com --json
24
-
25
- # Markdown review format (jakubkrehel-compatible)
26
- npx @designesy/score stripe.com --format review
27
-
28
- # Scoring scope: contract (strict, all checks penalize absence) or universal (fair to external sites)
29
- npx @designesy/score designesy.org --scope contract
30
- ```
31
-
32
- ### Options
33
-
34
- | Flag | Description |
35
- |---|---|
36
- | `--format <f>` | Emission format: `designesy` (default), `canonical`, `review`, `google` |
37
- | `--scope <s>` | Scoring scope: `contract` (strict) or `universal` (fair, default auto-detect) |
38
- | `--min-score <n>` | Exit 1 if score < n |
39
- | `--min-grade <g>` | Exit 1 if grade worse than g (A/B/C/D/F) |
40
- | `--json` | Output raw JSON (no formatted report) |
41
- | `--quiet` | Only output on failure (CI noise reduction) |
42
- | `--help, -h` | Show help |
43
-
44
- ## Library
45
-
46
- ```typescript
47
- import { scoreUrl } from '@designesy/score';
48
-
49
- const result = await scoreUrl('https://linear.app');
50
- console.log(result.score); // 72.5
51
- console.log(result.grade); // 'C'
52
- console.log(result.pass); // 28
53
- console.log(result.fail); // 3
54
- console.log(result.checks); // [{ id: 'v01', status: 'PASS', detail: '...' }, ...]
55
- ```
56
-
57
- ## The 42 checks
58
-
59
- | Category | Checks | Weight |
60
- |---|---|---|
61
- | cadence | v14, v15, v16, v17, v18, v19, v20, v26, v28, x01, x02, x03 | 18 |
62
- | accessibility | v06, v22, v24, v25, v27, v35 | 15 |
63
- | semantic | v42, v43 | 12 |
64
- | motion | v05, v11, v12, v23 | 10 |
65
- | tokens | v01, v29 | 9 |
66
- | takt | v10, v13 | 8 |
67
- | copywriting | v38, v39, v40, v41 | 8 |
68
- | poise | v04, v08, v09 | 7 |
69
- | identity | v07, v34 | 6 |
70
- | interaction | v03 | 6 |
71
- | performance | v21 | 6 |
72
- | security | v36 | 5 |
73
- | spec | v37 | 4 |
74
- | responsive | v02 | 3 |
75
-
76
- Plus 12 anti-slop deductions (S1 to S12) and 7 originality lifts (O1 to O7).
77
-
78
- ## Scoring
79
-
80
- - Weighted per-category scoring (PASS = 1.0, WARN = 0.5, FAIL = 0, SKIP/MANUAL excluded)
81
- - Anti-slop deductions (up to -20pts for generic AI patterns)
82
- - Originality lifts (up to +8pts for bespoke craft signals)
83
- - Accessibility floor (score capped at C/70 when any a11y FAIL exists)
84
- - Hard-fail ceilings (critical issues cap the score further)
85
-
86
- ## Scope system
87
-
88
- - **contract** (default for designesy.org): All 42 checks active. Absence = WARN/FAIL.
89
- - **universal** (default for external sites): Optional features SKIP on absence instead of penalizing.
90
-
91
- Auto-detect: `designesy.org` → contract, everything else → universal.
92
-
93
- ## Zero dependencies
94
-
95
- Uses only Node.js built-ins:
96
- - `node:https` for URL fetching (avoids the Windows libuv/undici crash)
97
- - `node:dns` for SSRF guard (DNS resolution validation)
98
- - No `ipaddr.js`, no `undici`, no `fetch()`, no external packages
99
-
100
- ## SSRF guard
101
-
102
- Built-in SSRF protection rejects:
103
- - Private IP ranges (10.x, 127.x, 169.254.x, 172.16-31.x, 192.168.x)
104
- - Cloud metadata endpoints (169.254.169.254)
105
- - All IPv6 addresses
106
- - Encoded IP bypasses (decimal, octal, hex)
107
- - Redirect chains to internal addresses
108
-
109
- ## License
110
-
1
+ # @designesy/score
2
+
3
+ Standalone 42-check design-contract scoring engine. Fetches a URL, extracts CSS + `:root` tokens, runs all checks locally, with **no server required**. Zero dependencies.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install --save-dev @designesy/score
9
+ # or
10
+ npx @designesy/score <url>
11
+ ```
12
+
13
+ ## CLI
14
+
15
+ ```bash
16
+ # Score a site (prints formatted report)
17
+ npx @designesy/score designesy.org
18
+
19
+ # CI gate: fail if below threshold
20
+ npx @designesy/score linear.app --min-score 70 --min-grade B
21
+
22
+ # JSON output for pipelines
23
+ npx @designesy/score vercel.com --json
24
+
25
+ # Markdown review format (jakubkrehel-compatible)
26
+ npx @designesy/score stripe.com --format review
27
+
28
+ # Scoring scope: contract (strict, all checks penalize absence) or universal (fair to external sites)
29
+ npx @designesy/score designesy.org --scope contract
30
+ ```
31
+
32
+ ### Options
33
+
34
+ | Flag | Description |
35
+ |---|---|
36
+ | `--format <f>` | Emission format: `designesy` (default), `canonical`, `review`, `google` |
37
+ | `--scope <s>` | Scoring scope: `contract` (strict) or `universal` (fair, default auto-detect) |
38
+ | `--min-score <n>` | Exit 1 if score < n |
39
+ | `--min-grade <g>` | Exit 1 if grade worse than g (A/B/C/D/F) |
40
+ | `--json` | Output raw JSON (no formatted report) |
41
+ | `--quiet` | Only output on failure (CI noise reduction) |
42
+ | `--help, -h` | Show help |
43
+
44
+ ## Library
45
+
46
+ ```typescript
47
+ import { scoreUrl } from '@designesy/score';
48
+
49
+ const result = await scoreUrl('https://linear.app');
50
+ console.log(result.score); // 72.5
51
+ console.log(result.grade); // 'C'
52
+ console.log(result.pass); // 28
53
+ console.log(result.fail); // 3
54
+ console.log(result.checks); // [{ id: 'v01', status: 'PASS', detail: '...' }, ...]
55
+ ```
56
+
57
+ ## The 42 checks
58
+
59
+ | Category | Checks | Weight |
60
+ |---|---|---|
61
+ | cadence | v14, v15, v16, v17, v18, v19, v20, v26, v28, x01, x02, x03 | 18 |
62
+ | accessibility | v06, v22, v24, v25, v27, v35 | 15 |
63
+ | semantic | v42, v43 | 12 |
64
+ | motion | v05, v11, v12, v23 | 10 |
65
+ | tokens | v01, v29 | 9 |
66
+ | takt | v10, v13 | 8 |
67
+ | copywriting | v38, v39, v40, v41 | 8 |
68
+ | poise | v04, v08, v09 | 7 |
69
+ | identity | v07, v34 | 6 |
70
+ | interaction | v03 | 6 |
71
+ | performance | v21 | 6 |
72
+ | security | v36 | 5 |
73
+ | spec | v37 | 4 |
74
+ | responsive | v02 | 3 |
75
+
76
+ Plus 12 anti-slop deductions (S1 to S12) and 7 originality lifts (O1 to O7).
77
+
78
+ ## Scoring
79
+
80
+ - Weighted per-category scoring (PASS = 1.0, WARN = 0.5, FAIL = 0, SKIP/MANUAL excluded)
81
+ - Anti-slop deductions (up to -20pts for generic AI patterns)
82
+ - Originality lifts (up to +8pts for bespoke craft signals)
83
+ - Accessibility floor (score capped at C/70 when any a11y FAIL exists)
84
+ - Hard-fail ceilings (critical issues cap the score further)
85
+
86
+ ## Scope system
87
+
88
+ - **contract** (default for designesy.org): All 42 checks active. Absence = WARN/FAIL.
89
+ - **universal** (default for external sites): Optional features SKIP on absence instead of penalizing.
90
+
91
+ Auto-detect: `designesy.org` → contract, everything else → universal.
92
+
93
+ ## Zero dependencies
94
+
95
+ Uses only Node.js built-ins:
96
+ - `node:https` for URL fetching (avoids the Windows libuv/undici crash)
97
+ - `node:dns` for SSRF guard (DNS resolution validation)
98
+ - No `ipaddr.js`, no `undici`, no `fetch()`, no external packages
99
+
100
+ ## SSRF guard
101
+
102
+ Built-in SSRF protection rejects:
103
+ - Private IP ranges (10.x, 127.x, 169.254.x, 172.16-31.x, 192.168.x)
104
+ - Cloud metadata endpoints (169.254.169.254)
105
+ - All IPv6 addresses
106
+ - Encoded IP bypasses (decimal, octal, hex)
107
+ - Redirect chains to internal addresses
108
+
109
+ ## License
110
+
111
111
  MIT © [Designesy](https://www.designesy.org)
package/dist/cli.js CHANGED
@@ -97,29 +97,29 @@ function formatReport(result, url) {
97
97
  return lines.join('\n');
98
98
  }
99
99
  function printUsage() {
100
- console.log(`
101
- designesy-score: Score a URL against the 42-check Designesy engine.
102
-
103
- Runs locally: no server required. Fetches the URL, extracts CSS + tokens,
104
- and runs all 42 checks in one process.
105
-
106
- Usage:
107
- designesy-score <url> [options]
108
-
109
- Options:
110
- --format <f> Emission format: designesy (default), canonical, review, google
111
- --scope <s> Scoring scope: contract (strict) or universal (fair, default auto)
112
- --min-score <n> Exit 1 if score < n (default: 0 = disabled)
113
- --min-grade <g> Exit 1 if grade worse than g (A/B/C/D/F, default: disabled)
114
- --json Output raw JSON (no formatted report)
115
- --quiet Only output on failure
116
- --help, -h Show this help
117
-
118
- Examples:
119
- designesy-score designesy.org
120
- designesy-score linear.app --min-score 70 --min-grade B
121
- designesy-score vercel.com --format canonical --json
122
- designesy-score stripe.com --format review
100
+ console.log(`
101
+ designesy-score: Score a URL against the 42-check Designesy engine.
102
+
103
+ Runs locally: no server required. Fetches the URL, extracts CSS + tokens,
104
+ and runs all 42 checks in one process.
105
+
106
+ Usage:
107
+ designesy-score <url> [options]
108
+
109
+ Options:
110
+ --format <f> Emission format: designesy (default), canonical, review, google
111
+ --scope <s> Scoring scope: contract (strict) or universal (fair, default auto)
112
+ --min-score <n> Exit 1 if score < n (default: 0 = disabled)
113
+ --min-grade <g> Exit 1 if grade worse than g (A/B/C/D/F, default: disabled)
114
+ --json Output raw JSON (no formatted report)
115
+ --quiet Only output on failure
116
+ --help, -h Show this help
117
+
118
+ Examples:
119
+ designesy-score designesy.org
120
+ designesy-score linear.app --min-score 70 --min-grade B
121
+ designesy-score vercel.com --format canonical --json
122
+ designesy-score stripe.com --format review
123
123
  `);
124
124
  }
125
125
  function parseArgs(argv) {
@@ -189,8 +189,10 @@ async function main() {
189
189
  let scope;
190
190
  if (args.scope === 'contract' || args.scope === 'universal')
191
191
  scope = args.scope;
192
+ // Progress and the gate verdict go to stderr: stdout carries only the
193
+ // result, so --json and the JSON formats pipe straight into a parser.
192
194
  if (!args.quiet) {
193
- console.log(`${DIM}Scoring ${url} locally (42-check engine, scope=${scope || 'auto'})…${RESET}`);
195
+ console.error(`${DIM}Scoring ${url} locally (42-check engine, scope=${scope || 'auto'})…${RESET}`);
194
196
  }
195
197
  let result;
196
198
  try {
@@ -238,7 +240,7 @@ async function main() {
238
240
  // gate, and it must not be compared against a floor — there is no measurement
239
241
  // to compare. This is the exit-code consequence of the unreachable state.
240
242
  if (score === null || grade === null) {
241
- console.error(`
243
+ console.error(`
242
244
  ${BOLD}Quality gate failed${RESET}: ${result.unreachableDetail ?? 'the target could not be read'}`);
243
245
  process.exit(1);
244
246
  }
@@ -251,7 +253,7 @@ ${BOLD}Quality gate failed${RESET}: ${result.unreachableDetail ?? 'the target co
251
253
  process.exit(1);
252
254
  }
253
255
  if (!args.quiet) {
254
- console.log(`\n${BOLD}Quality gate passed${RESET}`);
256
+ console.error(`\n${BOLD}Quality gate passed${RESET}`);
255
257
  }
256
258
  }
257
259
  main().catch((e) => {
package/dist/engine.js CHANGED
@@ -24,7 +24,11 @@ const TIER2_ABSENCE_PATTERNS = [
24
24
  { id: 'v08', absenceMatch: /^missing:/ },
25
25
  { id: 'v10', absenceMatch: /^missing:/ },
26
26
  { id: 'v13', absenceMatch: /^no press-scale|only scale\(0\)|no press-scale \(scale/ },
27
+ // v14 and v18 are Designesy Cadence taste; Tier 1 until engine 1.1.0. See the
28
+ // notes on the same entries in apps/site/app/api/score/route.ts.
29
+ { id: 'v14', absenceMatch: /^missing:/ },
27
30
  { id: 'v15', absenceMatch: /missing complete font-smoothing/ },
31
+ { id: 'v18', absenceMatch: /^balance=(?:true|false) pretty=(?:true|false)$/ },
28
32
  { id: 'v19', absenceMatch: /^only \d+ instances/ },
29
33
  { id: 'v20', absenceMatch: /^no ::selection rule found/ },
30
34
  { id: 'v23', absenceMatch: /^only \d+\/5 duration tokens|no.*duration tokens/i },
@@ -637,6 +641,43 @@ function checkTabularNums(css) {
637
641
  return { id: 'v19', item: 'tabular-nums: 8 instances across the live CSS', category: 'cadence', status: 'PASS', detail: `${count} instances found` };
638
642
  return { id: 'v19', item: 'tabular-nums: 8 instances across the live CSS', category: 'cadence', status: 'WARN', detail: `only ${count} instances (threshold: 8)` };
639
643
  }
644
+ /**
645
+ * v05 helper: does a `prefers-reduced-motion: no-preference` block opt INTO
646
+ * motion?
647
+ *
648
+ * Gating motion behind `no-preference` is a recommended way to honour reduced
649
+ * motion: the motion exists only for users who have not asked for less, so a
650
+ * `reduce` preference receives none of it. The block counts when it declares an
651
+ * animation, a transition, smooth scrolling or a view transition.
652
+ *
653
+ * A block that only switches motion off (`animation: none`, durations of 10ms or
654
+ * less) is the `reduce` kill switch filed under the wrong query. It stills motion
655
+ * for users with no preference and leaves it running for users who asked for
656
+ * less, which inverts the preference, so it does not count. The calibration
657
+ * fixture broken-reduced-motion-inverted pins that case.
658
+ */
659
+ function optsIntoMotion(body) {
660
+ if (/@view-transition\s*\{[^}]*navigation\s*:\s*auto/i.test(body))
661
+ return true;
662
+ const declRe = /(?:^|[{;\s])(animation|animation-name|animation-duration|transition|transition-property|transition-duration|scroll-behavior|view-transition-name)\s*:\s*([^;{}]+)/gi;
663
+ let m;
664
+ while ((m = declRe.exec(body)) !== null) {
665
+ const prop = m[1].toLowerCase();
666
+ const value = m[2].replace(/!important/i, '').trim().toLowerCase();
667
+ if (prop === 'scroll-behavior') {
668
+ if (value === 'smooth')
669
+ return true;
670
+ continue;
671
+ }
672
+ if (/^(?:none|initial|unset|revert|revert-layer|0|0s|0ms)$/.test(value))
673
+ continue;
674
+ const times = [...value.matchAll(/(?:^|[\s,(])(\d*\.?\d+)(ms|s)\b/g)].map((t) => parseFloat(t[1]) * (t[2] === 's' ? 1000 : 1));
675
+ if (times.length > 0 && times.every((t) => t <= 10))
676
+ continue;
677
+ return true;
678
+ }
679
+ return false;
680
+ }
640
681
  function checkReducedMotion(css) {
641
682
  const ITEM = 'prefers-reduced-motion disables entrance and wordmark breath';
642
683
  const CATEGORY = 'motion';
@@ -645,15 +686,19 @@ function checkReducedMotion(css) {
645
686
  // calibration corpus caught exactly that: the previous regex matched the
646
687
  // string anywhere, including inside /* */.
647
688
  const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
648
- // Match the media query and require the `reduce` VALUE specifically.
649
- // The prior regex accepted any @media containing the feature name, so
650
- // `@media (prefers-reduced-motion: NO-PREFERENCE)` — which expresses the
651
- // OPPOSITE intent, opting INTO motion — passed as readily as `reduce`. That
652
- // is a false PASS on the exact accessibility primitive the check exists to
653
- // verify. `no-preference` is not a reduced-motion block.
689
+ // Match the media query and read its VALUE. Two shapes honour reduced motion:
690
+ // * `reduce` with a non-empty body: motion is switched off for those users.
691
+ // * `no-preference` with a body that opts INTO motion (optsIntoMotion): the
692
+ // motion is opt-in, so a reduce preference never receives it.
693
+ // Until engine 1.1.0 only the first shape passed, and a site that gated all
694
+ // of its motion behind `no-preference` was WARNed for following a recommended
695
+ // pattern. A bare substring match on the feature name stays wrong in both
696
+ // directions: an empty block, or a `no-preference` block that only switches
697
+ // motion off, honours nothing and still WARNs.
654
698
  const mqRe = /@media[^{]*prefers-reduced-motion\s*:\s*(reduce|no-preference)\b[^{]*\{/gi;
655
699
  let match;
656
700
  let sawReduce = false;
701
+ let sawMotionOptIn = false;
657
702
  let sawNoPreference = false;
658
703
  while ((match = mqRe.exec(code)) !== null) {
659
704
  const value = match[1].toLowerCase();
@@ -675,6 +720,8 @@ function checkReducedMotion(css) {
675
720
  const body = close > open ? code.slice(open + 1, close).trim() : '';
676
721
  if (value === 'no-preference') {
677
722
  sawNoPreference = true;
723
+ if (optsIntoMotion(body))
724
+ sawMotionOptIn = true;
678
725
  continue;
679
726
  }
680
727
  // A `reduce` query that declares no rules reduces nothing.
@@ -684,10 +731,13 @@ function checkReducedMotion(css) {
684
731
  if (sawReduce) {
685
732
  return { id: 'v05', item: ITEM, category: CATEGORY, status: 'PASS', detail: 'prefers-reduced-motion: reduce block declares rules' };
686
733
  }
734
+ if (sawMotionOptIn) {
735
+ return { id: 'v05', item: ITEM, category: CATEGORY, status: 'PASS', detail: 'motion is opt-in: declared inside a prefers-reduced-motion: no-preference block, so a reduce preference receives none of it' };
736
+ }
687
737
  if (sawNoPreference) {
688
738
  return {
689
739
  id: 'v05', item: ITEM, category: CATEGORY, status: 'WARN',
690
- detail: 'a prefers-reduced-motion media query exists but uses `no-preference`: that opts INTO motion rather than reducing it. Reduced-motion support requires the `reduce` value.',
740
+ detail: 'a prefers-reduced-motion: no-preference block exists but gates no motion: it is empty, or it only switches motion off, which stills motion for users with no preference and leaves it running for users who asked for less. Declare motion inside no-preference, or switch it off inside reduce.',
691
741
  };
692
742
  }
693
743
  return { id: 'v05', item: ITEM, category: CATEGORY, status: 'WARN', detail: 'missing prefers-reduced-motion: reduce media query' };
@@ -1022,9 +1072,33 @@ function checkFontFamilyCount(css, tokens) {
1022
1072
  return { id: 'v26', item: 'Font family count ≤3 (body + heading + mono)', category: 'cadence', status: 'WARN', detail: `${count} families (recommended ≤3): ${list}` };
1023
1073
  return { id: 'v26', item: 'Font family count ≤3 (body + heading + mono)', category: 'cadence', status: 'FAIL', detail: `${count} families, palette drift (recommended ≤3): ${list}` };
1024
1074
  }
1075
+ /**
1076
+ * v27 helper: does the delivered markup carry a field iOS Safari zooms into on
1077
+ * focus? That is a <textarea>, a <select>, or an <input> of any type except the
1078
+ * ones that take no typed text: hidden, checkbox, radio, submit, button, reset,
1079
+ * image, file, range and color. An <input> with no type is a text field.
1080
+ */
1081
+ function hasZoomableField(html) {
1082
+ const visible = html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
1083
+ if (/<(?:textarea|select)\b/i.test(visible))
1084
+ return true;
1085
+ const inputs = visible.match(/<input\b[^>]*>/gi) || [];
1086
+ return inputs.some((tag) => {
1087
+ const type = /\stype\s*=\s*["']?([a-z-]+)/i.exec(tag);
1088
+ return !type || !/^(?:hidden|checkbox|radio|submit|button|reset|image|file|range|color)$/i.test(type[1]);
1089
+ });
1090
+ }
1025
1091
  // v27 — Input font-size 16px floor (iOS Safari auto-zoom prevention).
1026
1092
  // Scans CSS for input/textarea/select font-size below 16px (1rem).
1027
- function checkInputFontFloor(css) {
1093
+ //
1094
+ // Since engine 1.1.0 the page's own markup decides applicability. When the CSS
1095
+ // shows neither a sub-16px input rule nor a 16px floor, a page with no field iOS
1096
+ // can zoom into (hasZoomableField) has nothing to check and reads SKIP, as v38
1097
+ // does for a page with no buttons. It used to read WARN. The CSS verdicts are
1098
+ // unchanged: a sub-16px input rule still FAILs and a declared floor still PASSes,
1099
+ // because both describe the stylesheet that fields rendered later by script
1100
+ // would also inherit.
1101
+ function checkInputFontFloor(css, html) {
1028
1102
  // Find input/textarea/select rules with font-size < 16px or < 1rem.
1029
1103
  const inputRe = /(?:input|textarea|select|\.input|\.field)[^{]*\{[^}]*font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gi;
1030
1104
  const below = [];
@@ -1044,6 +1118,8 @@ function checkInputFontFloor(css) {
1044
1118
  || /input\s*[,][^{]*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(css);
1045
1119
  if (hasGlobalFloor)
1046
1120
  return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'PASS', detail: 'input font-size floor detected' };
1121
+ if (!hasZoomableField(html))
1122
+ return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'SKIP', detail: 'no text input, textarea or select found in HTML: nothing for the 16px floor to protect' };
1047
1123
  return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'WARN', detail: 'no explicit input font-size ≥16px detected: iOS Safari may auto-zoom on focus' };
1048
1124
  }
1049
1125
  return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'FAIL', detail: `${below.length} input(s) below 16px floor: ${below.join(', ')}` };
@@ -2035,13 +2111,14 @@ async function checkDesignMdSpec(targetUrl) {
2035
2111
  lintDesignMd = mod.lint;
2036
2112
  }
2037
2113
  catch (e) {
2038
- // WARN, not PASS. The file was fetched but NOTHING validated it: reporting
2039
- // PASS here asserted "served with valid frontmatter" on the strength of the
2040
- // frontmatter check alone — which the branch above already ran, and which
2041
- // would have WARNed rather than reached here. Implying validation that never
2042
- // happened is the same false-PASS class as the all-SKIP review verdict.
2114
+ // MANUAL, not PASS and not WARN. The file was fetched but NOTHING validated
2115
+ // it: PASS would assert validation that never happened (the false-PASS
2116
+ // class of the all-SKIP review verdict). WARN charged the scored site for a
2117
+ // package missing from THIS runtime, so the same page scored lower from the
2118
+ // npm CLI than from the API. Unverified-here is what MANUAL means (v02,
2119
+ // v04, v21): weight 0, resolved by a run that has the linter.
2043
2120
  const msg = e instanceof Error ? e.message : 'unknown error';
2044
- return { id: 'v37', item: ITEM, category: CATEGORY, status: 'WARN', detail: `/DESIGN.md fetched but linter unavailable: ${msg}. The @google/design.md package may not be installed in this runtime.` };
2121
+ return { id: 'v37', item: ITEM, category: CATEGORY, status: 'MANUAL', detail: `/DESIGN.md fetched but linter unavailable: ${msg}. The @google/design.md package may not be installed in this runtime; run the full audit to resolve.` };
2045
2122
  }
2046
2123
  try {
2047
2124
  const report = lintDesignMd(r.text);
@@ -2171,7 +2248,7 @@ export async function scoreFromParts(input) {
2171
2248
  checkTouchTargets(css),
2172
2249
  checkHeadingHierarchy(html),
2173
2250
  checkFontFamilyCount(css, tokens),
2174
- checkInputFontFloor(css),
2251
+ checkInputFontFloor(css, html),
2175
2252
  checkReadingWidth(css),
2176
2253
  checkTokenLayerDepth(tokens),
2177
2254
  checkSemanticColorVocabulary(tokens),
package/package.json CHANGED
@@ -1,55 +1,55 @@
1
- {
2
- "name": "@designesy/score",
3
- "version": "0.5.0",
4
- "description": "Standalone 42-check design-contract scoring engine: fetches a URL, extracts CSS + tokens, and runs all checks locally. Zero dependencies.",
5
- "license": "MIT",
6
- "author": "Designesy <hello@designesy.org>",
7
- "homepage": "https://www.designesy.org/score",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/LE-VAI/designesy-org.git",
11
- "directory": "packages/score"
12
- },
13
- "bugs": {
14
- "url": "https://github.com/LE-VAI/designesy-org/issues"
15
- },
16
- "keywords": [
17
- "design-system",
18
- "design-contract",
19
- "design-verification",
20
- "accessibility",
21
- "wcag",
22
- "core-web-vitals",
23
- "ci",
24
- "quality-gate",
25
- "designesy"
26
- ],
27
- "type": "module",
28
- "exports": {
29
- ".": {
30
- "import": "./dist/engine.js",
31
- "types": "./dist/engine.d.ts"
32
- }
33
- },
34
- "main": "./dist/engine.js",
35
- "types": "./dist/engine.d.ts",
36
- "bin": {
37
- "designesy-score": "dist/cli.js"
38
- },
39
- "files": [
40
- "dist",
41
- "README.md"
42
- ],
43
- "engines": {
44
- "node": ">=18.0.0"
45
- },
46
- "devDependencies": {
47
- "@types/node": "^26.2.0",
48
- "typescript": "^5.7.0"
49
- },
50
- "scripts": {
51
- "build": "tsc -p tsconfig.json",
52
- "test": "node --test",
53
- "prepublishOnly": "npm run build"
54
- }
55
- }
1
+ {
2
+ "name": "@designesy/score",
3
+ "version": "0.6.0",
4
+ "description": "Standalone 42-check design-contract scoring engine: fetches a URL, extracts CSS + tokens, and runs all checks locally. Zero dependencies.",
5
+ "license": "MIT",
6
+ "author": "Designesy <hello@designesy.org>",
7
+ "homepage": "https://www.designesy.org/score",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/LE-VAI/designesy-org.git",
11
+ "directory": "packages/score"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/LE-VAI/designesy-org/issues"
15
+ },
16
+ "keywords": [
17
+ "design-system",
18
+ "design-contract",
19
+ "design-verification",
20
+ "accessibility",
21
+ "wcag",
22
+ "core-web-vitals",
23
+ "ci",
24
+ "quality-gate",
25
+ "designesy"
26
+ ],
27
+ "type": "module",
28
+ "exports": {
29
+ ".": {
30
+ "import": "./dist/engine.js",
31
+ "types": "./dist/engine.d.ts"
32
+ }
33
+ },
34
+ "main": "./dist/engine.js",
35
+ "types": "./dist/engine.d.ts",
36
+ "bin": {
37
+ "designesy-score": "dist/cli.js"
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "README.md"
42
+ ],
43
+ "engines": {
44
+ "node": ">=18.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^26.2.0",
48
+ "typescript": "^5.7.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.json",
52
+ "test": "node --test",
53
+ "prepublishOnly": "npm run build"
54
+ }
55
+ }