@designesy/score 0.5.1 → 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 (2) hide show
  1. package/dist/engine.js +85 -9
  2. package/package.json +1 -1
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(', ')}` };
@@ -2172,7 +2248,7 @@ export async function scoreFromParts(input) {
2172
2248
  checkTouchTargets(css),
2173
2249
  checkHeadingHierarchy(html),
2174
2250
  checkFontFamilyCount(css, tokens),
2175
- checkInputFontFloor(css),
2251
+ checkInputFontFloor(css, html),
2176
2252
  checkReadingWidth(css),
2177
2253
  checkTokenLayerDepth(tokens),
2178
2254
  checkSemanticColorVocabulary(tokens),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@designesy/score",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Standalone 42-check design-contract scoring engine: fetches a URL, extracts CSS + tokens, and runs all checks locally. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Designesy <hello@designesy.org>",