@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.
- package/dist/engine.js +85 -9
- 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
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|
|
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>",
|