@domandigital/craft 0.3.0 → 0.5.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/CHANGELOG.md +60 -0
- package/STANDARD.md +37 -7
- package/dist/index.cjs +122 -17
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +122 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# @domandigital/craft
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f49922c: A font size in `em` or `%` counts against its own budget, not the type scale's.
|
|
8
|
+
|
|
9
|
+
`code { font-size: 0.875em }` does not add a step to a scale. It states one
|
|
10
|
+
relationship — code is 87.5% of whatever it sits in — and applies it wherever
|
|
11
|
+
code appears. Counting it as a step is the same category error section 9 already
|
|
12
|
+
corrected once for the framework's theme: a number that is not part of the
|
|
13
|
+
vocabulary a reader has to hold.
|
|
14
|
+
|
|
15
|
+
They are still counted, under `maxRelativeFontSizes` (house value 3), because
|
|
16
|
+
the failure they can cause is real — eight competing ratios is as unreadable as
|
|
17
|
+
eight competing steps. Split, not dropped. A value that stops being reported is
|
|
18
|
+
a value that grows.
|
|
19
|
+
|
|
20
|
+
`counts.relativeFontSizes` is new; `counts.fontSizes` now excludes them, so a
|
|
21
|
+
surface using `em` will report a lower scale count than under 0.4.0.
|
|
22
|
+
|
|
23
|
+
Only bare ratios move: `0.875em` and `75%` are relative, `0.875rem` is not, and
|
|
24
|
+
neither is a composite like `max(16px, 1em)`.
|
|
25
|
+
|
|
26
|
+
## 0.4.0
|
|
27
|
+
|
|
28
|
+
### Minor Changes
|
|
29
|
+
|
|
30
|
+
- 1b2e8bf: `checkRestraint` resolves token references instead of skipping them.
|
|
31
|
+
|
|
32
|
+
The old rule skipped any value starting with `var(`, on the reasoning that a
|
|
33
|
+
token reference is the token layer doing its job rather than a new value. True
|
|
34
|
+
of a hand-written sheet. On a Tailwind v4 sheet it is close to measuring
|
|
35
|
+
nothing: `text-sm` compiles to `font-size: var(--text-sm)` and `rounded-lg` to
|
|
36
|
+
`border-radius: var(--radius-lg)`, and `shadow-[…]` emits no `box-shadow` at
|
|
37
|
+
all — it assigns `--tw-shadow` and lets one shared composite read it.
|
|
38
|
+
|
|
39
|
+
Measured on the estate this was hiding, per surface: 19 font sizes reported
|
|
40
|
+
against 28 shipped, 5 shadows against 18, 7 radii against 13. A second surface
|
|
41
|
+
reported `ok: true` on every axis and is over budget on four.
|
|
42
|
+
|
|
43
|
+
What changes:
|
|
44
|
+
|
|
45
|
+
- A value that is exactly one `var()` is resolved through the token chain to
|
|
46
|
+
the value that renders, and counted.
|
|
47
|
+
- `--tw-shadow` assignments count as shadows; Tailwind's composite plumbing
|
|
48
|
+
value does not, and neither does its `0 0 #0000` initial.
|
|
49
|
+
- `var(--tw-shadow-color, X)` is unwrapped to `X`, so the report names what a
|
|
50
|
+
reader would see.
|
|
51
|
+
- Definitions are read from the whole sheet, including ignored layers, while
|
|
52
|
+
usage is still read only from kept ones. The layer exclusion is unchanged in
|
|
53
|
+
intent and now works: a framework default nothing uses still counts for
|
|
54
|
+
nothing. Used versus unused was always the real distinction; which layer the
|
|
55
|
+
definition sat in never was.
|
|
56
|
+
- New `unresolved-token` warning for a declaration whose token is defined
|
|
57
|
+
nowhere and which carries no fallback. It applies nothing, so it is not
|
|
58
|
+
vocabulary — it is a bug, and it now says so. It found two on first contact.
|
|
59
|
+
|
|
60
|
+
**Every consumer's baseline will move**, upward, on surfaces built with a
|
|
61
|
+
utility framework. That is the correction, not a regression.
|
|
62
|
+
|
|
3
63
|
## 0.3.0
|
|
4
64
|
|
|
5
65
|
### Minor Changes
|
package/STANDARD.md
CHANGED
|
@@ -113,17 +113,46 @@ Images: AVIF and WebP, in that order.
|
|
|
113
113
|
Per surface: **≤10 font sizes, ≤3 weights, ≤2 families** (plus mono),
|
|
114
114
|
**≤4 radii, ≤4 shadows**, accent hues clustered within **15° OKLCh**.
|
|
115
115
|
|
|
116
|
+
A size written in `em` or `%` counts separately, **≤3**. It is a ratio, not a
|
|
117
|
+
step: `code { font-size: 0.875em }` states one relationship and applies it
|
|
118
|
+
wherever code appears, rather than adding a rung to the ladder. Counting it
|
|
119
|
+
against the scale is the same category error as counting the framework's theme.
|
|
120
|
+
It is still counted, because eight competing ratios is as unreadable as eight
|
|
121
|
+
competing steps — split, not dropped. A value that stops being reported is a
|
|
122
|
+
value that grows.
|
|
123
|
+
|
|
116
124
|
Differentiation guards pass anything that differs from its siblings, however
|
|
117
125
|
ugly. What they cannot see is accumulation — eighteen font sizes, six shadows,
|
|
118
126
|
four competing accents, each added reasonably, together reading as noise.
|
|
119
127
|
|
|
120
|
-
The budget counts the vocabulary a surface **chose
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
The budget counts the vocabulary a surface **chose**, and the whole difficulty
|
|
129
|
+
is telling that apart from what the framework brought.
|
|
130
|
+
|
|
131
|
+
The first rule drew the line at the layer: `@layer theme` and `@layer base` were
|
|
132
|
+
excluded, because counting them reported a surface as over budget for values its
|
|
133
|
+
author never wrote. Right about the noise, wrong about the boundary. Tailwind v4
|
|
134
|
+
compiles `text-sm` to `font-size: var(--text-sm)` and `rounded-lg` to
|
|
135
|
+
`border-radius: var(--radius-lg)`, so with the definitions excluded *and* token
|
|
136
|
+
references skipped, a surface using `text-sm` seven hundred times reported it
|
|
137
|
+
zero times. One estate measured 19 font sizes and shipped 28; 5 shadows and
|
|
138
|
+
shipped 18. A count wrong in the under-reporting direction is worse than the
|
|
139
|
+
over-reporting kind it replaced: the first buries real findings in noise, the
|
|
140
|
+
second reports a pass.
|
|
141
|
+
|
|
142
|
+
The line is **used versus unused**, which is not a property of the layer:
|
|
143
|
+
|
|
144
|
+
- References are **resolved**, not skipped — through the token chain, to the
|
|
145
|
+
value that renders.
|
|
146
|
+
- A definition is only ever reached from a declaration in a kept layer, so a
|
|
147
|
+
framework default nothing uses still counts for nothing.
|
|
148
|
+
- `--tw-shadow` assignments count as shadows. `shadow-[…]` emits no literal
|
|
149
|
+
`box-shadow` at all; it assigns that property and lets one shared composite
|
|
150
|
+
read it.
|
|
151
|
+
- A reference that resolves to nothing is **not** vocabulary. It is a
|
|
152
|
+
declaration that silently applies nothing, and it is reported as its own
|
|
153
|
+
warning.
|
|
154
|
+
|
|
155
|
+
*Source: measured against the estate. The budget is the observed ceiling of the surfaces that read as designed; the resolution rule is what it took to measure them at all.*
|
|
127
156
|
|
|
128
157
|
## 10. What is deliberately not a rule
|
|
129
158
|
|
|
@@ -147,6 +176,7 @@ A rule here has to be one that is wrong to break. Everything else is a default.
|
|
|
147
176
|
{
|
|
148
177
|
"budget": {
|
|
149
178
|
"maxFontSizes": 10,
|
|
179
|
+
"maxRelativeFontSizes": 3,
|
|
150
180
|
"maxFontWeights": 3,
|
|
151
181
|
"maxFontFamilies": 2,
|
|
152
182
|
"maxRadii": 4,
|
package/dist/index.cjs
CHANGED
|
@@ -812,6 +812,7 @@ function densityTokens(name, rootPx = 16) {
|
|
|
812
812
|
// src/restraint/index.ts
|
|
813
813
|
var HOUSE_BUDGET = {
|
|
814
814
|
maxFontSizes: 10,
|
|
815
|
+
maxRelativeFontSizes: 3,
|
|
815
816
|
maxFontWeights: 3,
|
|
816
817
|
maxFontFamilies: 2,
|
|
817
818
|
maxRadii: 4,
|
|
@@ -852,10 +853,19 @@ function stripLayers(css, ignored) {
|
|
|
852
853
|
}
|
|
853
854
|
return out;
|
|
854
855
|
}
|
|
855
|
-
var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
856
|
+
var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
857
|
+
"inherit",
|
|
858
|
+
"initial",
|
|
859
|
+
"unset",
|
|
860
|
+
"revert",
|
|
861
|
+
"revert-layer"
|
|
862
|
+
]);
|
|
856
863
|
function atRuleBodies(css, prelude) {
|
|
857
864
|
const out = [];
|
|
858
|
-
const pattern = new RegExp(
|
|
865
|
+
const pattern = new RegExp(
|
|
866
|
+
prelude.source,
|
|
867
|
+
prelude.flags.includes("g") ? prelude.flags : `${prelude.flags}g`
|
|
868
|
+
);
|
|
859
869
|
let match;
|
|
860
870
|
while ((match = pattern.exec(css)) !== null) {
|
|
861
871
|
const start = match.index + match[0].length;
|
|
@@ -871,13 +881,64 @@ function atRuleBodies(css, prelude) {
|
|
|
871
881
|
}
|
|
872
882
|
return out;
|
|
873
883
|
}
|
|
874
|
-
function
|
|
884
|
+
function customProperties(css) {
|
|
885
|
+
const out = /* @__PURE__ */ new Map();
|
|
886
|
+
for (const match of css.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)/g)) {
|
|
887
|
+
out.set(match[1].toLowerCase(), match[2].trim().replace(/\s+/g, " "));
|
|
888
|
+
}
|
|
889
|
+
return out;
|
|
890
|
+
}
|
|
891
|
+
function unwrapShadowColor(value) {
|
|
892
|
+
return value.replace(
|
|
893
|
+
/var\(\s*--tw-shadow-color\s*,\s*([^)]*(?:\([^)]*\))?[^)]*)\)/g,
|
|
894
|
+
"$1"
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
var SINGLE_VAR = /^var\(\s*(--[\w-]+)\s*(?:,([\s\S]*))?\)$/;
|
|
898
|
+
function resolveValue(value, props, depth = 0) {
|
|
899
|
+
if (depth > 8) return null;
|
|
900
|
+
const match = SINGLE_VAR.exec(value.trim());
|
|
901
|
+
if (match === null) return value.trim();
|
|
902
|
+
const target = props.get(match[1].toLowerCase());
|
|
903
|
+
if (target !== void 0 && target !== "initial")
|
|
904
|
+
return resolveValue(target, props, depth + 1);
|
|
905
|
+
const fallback = match[2]?.trim();
|
|
906
|
+
return fallback === void 0 || fallback === "" ? null : resolveValue(fallback, props, depth + 1);
|
|
907
|
+
}
|
|
908
|
+
function isVarOnlyList(value) {
|
|
909
|
+
const parts = value.split(",");
|
|
910
|
+
return parts.length > 1 && parts.every((part) => /^\s*var\(\s*--[\w-]+\s*\)\s*$/.test(part));
|
|
911
|
+
}
|
|
912
|
+
function distinctValues(css, property, props, unresolved) {
|
|
875
913
|
const pattern = new RegExp(`(?:^|[;{\\s])${property}\\s*:\\s*([^;}]+)`, "gi");
|
|
876
914
|
const seen = /* @__PURE__ */ new Set();
|
|
877
915
|
for (const match of css.matchAll(pattern)) {
|
|
878
|
-
const
|
|
879
|
-
if (
|
|
880
|
-
|
|
916
|
+
const raw = match[1].trim().replace(/\s+/g, " ").toLowerCase();
|
|
917
|
+
if (CSS_WIDE_KEYWORDS.has(raw)) continue;
|
|
918
|
+
const value = unwrapShadowColor(raw);
|
|
919
|
+
if (isVarOnlyList(value)) continue;
|
|
920
|
+
if (!SINGLE_VAR.test(value)) {
|
|
921
|
+
seen.add(value);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (props === void 0) continue;
|
|
925
|
+
const resolved = resolveValue(value, props);
|
|
926
|
+
if (resolved === null) {
|
|
927
|
+
unresolved?.add(`${property}: ${value}`);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
seen.add(unwrapShadowColor(resolved).toLowerCase());
|
|
931
|
+
}
|
|
932
|
+
return [...seen];
|
|
933
|
+
}
|
|
934
|
+
function twShadowValues(css, props) {
|
|
935
|
+
const seen = /* @__PURE__ */ new Set();
|
|
936
|
+
for (const match of css.matchAll(/--tw-shadow\s*:\s*([^;}]+)/g)) {
|
|
937
|
+
const raw = match[1].trim().replace(/\s+/g, " ").toLowerCase();
|
|
938
|
+
if (raw === "0 0 #0000" || raw === "none") continue;
|
|
939
|
+
const value = SINGLE_VAR.test(raw) ? resolveValue(raw, props) : raw;
|
|
940
|
+
if (value === null) continue;
|
|
941
|
+
seen.add(unwrapShadowColor(value).toLowerCase());
|
|
881
942
|
}
|
|
882
943
|
return [...seen];
|
|
883
944
|
}
|
|
@@ -892,15 +953,28 @@ function durationsMs(css) {
|
|
|
892
953
|
}
|
|
893
954
|
function checkRestraint(input) {
|
|
894
955
|
const budget = { ...HOUSE_BUDGET, ...input.budget };
|
|
956
|
+
const whole = stripComments(input.css);
|
|
957
|
+
const props = customProperties(whole);
|
|
895
958
|
const css = stripLayers(
|
|
896
|
-
|
|
959
|
+
whole,
|
|
897
960
|
input.ignoreAtLayers ?? DEFAULT_IGNORED_LAYERS
|
|
898
961
|
);
|
|
962
|
+
const unresolved = /* @__PURE__ */ new Set();
|
|
899
963
|
const violations = [];
|
|
900
964
|
const push = (rule, severity, message, found) => {
|
|
901
965
|
violations.push({ rule, severity, message, found });
|
|
902
966
|
};
|
|
903
|
-
const
|
|
967
|
+
const allSizes = distinctValues(css, "font-size", props, unresolved);
|
|
968
|
+
const relativeSizes = allSizes.filter((v) => /^[\d.]+(em|%)$/.test(v));
|
|
969
|
+
const fontSizes = allSizes.filter((v) => !relativeSizes.includes(v));
|
|
970
|
+
if (relativeSizes.length > budget.maxRelativeFontSizes) {
|
|
971
|
+
push(
|
|
972
|
+
"relative-font-sizes",
|
|
973
|
+
"error",
|
|
974
|
+
`${relativeSizes.length} context-relative font sizes, budget ${budget.maxRelativeFontSizes}. A ratio is a relationship; more than a few and it is a second scale nobody named.`,
|
|
975
|
+
relativeSizes
|
|
976
|
+
);
|
|
977
|
+
}
|
|
904
978
|
if (fontSizes.length > budget.maxFontSizes) {
|
|
905
979
|
push(
|
|
906
980
|
"font-sizes",
|
|
@@ -909,10 +983,15 @@ function checkRestraint(input) {
|
|
|
909
983
|
fontSizes
|
|
910
984
|
);
|
|
911
985
|
}
|
|
912
|
-
const weights = distinctValues(css, "font-weight").filter(
|
|
913
|
-
|
|
914
|
-
(w) => w === "normal" || w === "bold"
|
|
986
|
+
const weights = distinctValues(css, "font-weight", props, unresolved).filter(
|
|
987
|
+
(w) => w !== "normal" && w !== "bold"
|
|
915
988
|
);
|
|
989
|
+
const namedWeights = distinctValues(
|
|
990
|
+
css,
|
|
991
|
+
"font-weight",
|
|
992
|
+
props,
|
|
993
|
+
unresolved
|
|
994
|
+
).filter((w) => w === "normal" || w === "bold");
|
|
916
995
|
const allWeights = [.../* @__PURE__ */ new Set([...weights, ...namedWeights])];
|
|
917
996
|
if (allWeights.length > budget.maxFontWeights) {
|
|
918
997
|
push(
|
|
@@ -922,7 +1001,7 @@ function checkRestraint(input) {
|
|
|
922
1001
|
allWeights
|
|
923
1002
|
);
|
|
924
1003
|
}
|
|
925
|
-
const families = distinctValues(css, "font-family").filter(
|
|
1004
|
+
const families = distinctValues(css, "font-family", props, unresolved).filter(
|
|
926
1005
|
(f) => !/\bmonospace\b|\bmono\b/.test(f)
|
|
927
1006
|
);
|
|
928
1007
|
if (families.length > budget.maxFontFamilies) {
|
|
@@ -933,11 +1012,23 @@ function checkRestraint(input) {
|
|
|
933
1012
|
families
|
|
934
1013
|
);
|
|
935
1014
|
}
|
|
936
|
-
const radii = distinctValues(css, "border-radius").filter(
|
|
1015
|
+
const radii = distinctValues(css, "border-radius", props, unresolved).filter(
|
|
1016
|
+
(r) => r !== "0" && r !== "0px"
|
|
1017
|
+
);
|
|
937
1018
|
if (radii.length > budget.maxRadii) {
|
|
938
|
-
push(
|
|
1019
|
+
push(
|
|
1020
|
+
"radii",
|
|
1021
|
+
"error",
|
|
1022
|
+
`${radii.length} corner radii, budget ${budget.maxRadii}.`,
|
|
1023
|
+
radii
|
|
1024
|
+
);
|
|
939
1025
|
}
|
|
940
|
-
const shadows =
|
|
1026
|
+
const shadows = [
|
|
1027
|
+
.../* @__PURE__ */ new Set([
|
|
1028
|
+
...distinctValues(css, "box-shadow", props, unresolved),
|
|
1029
|
+
...twShadowValues(css, props)
|
|
1030
|
+
])
|
|
1031
|
+
].filter((s) => s !== "none");
|
|
941
1032
|
if (shadows.length > budget.maxShadows) {
|
|
942
1033
|
push(
|
|
943
1034
|
"shadows",
|
|
@@ -946,6 +1037,14 @@ function checkRestraint(input) {
|
|
|
946
1037
|
shadows
|
|
947
1038
|
);
|
|
948
1039
|
}
|
|
1040
|
+
if (unresolved.size > 0) {
|
|
1041
|
+
push(
|
|
1042
|
+
"unresolved-token",
|
|
1043
|
+
"warning",
|
|
1044
|
+
`${unresolved.size} declaration(s) reference a custom property that is never defined and carry no fallback. Each one applies nothing.`,
|
|
1045
|
+
[...unresolved].sort()
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
949
1048
|
const accentHexes = [
|
|
950
1049
|
...css.matchAll(/--[a-z0-9-]*accent[a-z0-9-]*\s*:\s*(#[0-9a-fA-F]{3,8})/g)
|
|
951
1050
|
].map((m) => m[1]);
|
|
@@ -997,7 +1096,9 @@ function checkRestraint(input) {
|
|
|
997
1096
|
[...new Set(easeIn)]
|
|
998
1097
|
);
|
|
999
1098
|
}
|
|
1000
|
-
const overBudget = durationsMs(css).filter(
|
|
1099
|
+
const overBudget = durationsMs(css).filter(
|
|
1100
|
+
(d) => d.ms > budget.maxUiDurationMs
|
|
1101
|
+
);
|
|
1001
1102
|
if (overBudget.length > 0) {
|
|
1002
1103
|
push(
|
|
1003
1104
|
"duration",
|
|
@@ -1014,7 +1115,10 @@ function checkRestraint(input) {
|
|
|
1014
1115
|
["scale(0)"]
|
|
1015
1116
|
);
|
|
1016
1117
|
}
|
|
1017
|
-
const reducedMotionBlocks = atRuleBodies(
|
|
1118
|
+
const reducedMotionBlocks = atRuleBodies(
|
|
1119
|
+
css,
|
|
1120
|
+
/@media[^{]*prefers-reduced-motion:\s*reduce[^{]*\{/gi
|
|
1121
|
+
);
|
|
1018
1122
|
if (reducedMotionBlocks.length === 0) {
|
|
1019
1123
|
if (/@keyframes|animation\s*:|transition\s*:/.test(css)) {
|
|
1020
1124
|
push(
|
|
@@ -1053,6 +1157,7 @@ function checkRestraint(input) {
|
|
|
1053
1157
|
violations,
|
|
1054
1158
|
counts: {
|
|
1055
1159
|
fontSizes: fontSizes.length,
|
|
1160
|
+
relativeFontSizes: relativeSizes.length,
|
|
1056
1161
|
fontWeights: allWeights.length,
|
|
1057
1162
|
fontFamilies: families.length,
|
|
1058
1163
|
radii: radii.length,
|
package/dist/index.d.cts
CHANGED
|
@@ -600,6 +600,8 @@ declare function densityTokens(name: DensityName, rootPx?: number): Record<strin
|
|
|
600
600
|
*/
|
|
601
601
|
interface RestraintBudget {
|
|
602
602
|
maxFontSizes: number;
|
|
603
|
+
/** Context-relative font sizes (`em`, `%`) -- ratios, not steps. See section 9. */
|
|
604
|
+
maxRelativeFontSizes: number;
|
|
603
605
|
maxFontWeights: number;
|
|
604
606
|
/** Excludes the monospace face, which is a functional choice not a stylistic one. */
|
|
605
607
|
maxFontFamilies: number;
|
package/dist/index.d.ts
CHANGED
|
@@ -600,6 +600,8 @@ declare function densityTokens(name: DensityName, rootPx?: number): Record<strin
|
|
|
600
600
|
*/
|
|
601
601
|
interface RestraintBudget {
|
|
602
602
|
maxFontSizes: number;
|
|
603
|
+
/** Context-relative font sizes (`em`, `%`) -- ratios, not steps. See section 9. */
|
|
604
|
+
maxRelativeFontSizes: number;
|
|
603
605
|
maxFontWeights: number;
|
|
604
606
|
/** Excludes the monospace face, which is a functional choice not a stylistic one. */
|
|
605
607
|
maxFontFamilies: number;
|
package/dist/index.js
CHANGED
|
@@ -740,6 +740,7 @@ function densityTokens(name, rootPx = 16) {
|
|
|
740
740
|
// src/restraint/index.ts
|
|
741
741
|
var HOUSE_BUDGET = {
|
|
742
742
|
maxFontSizes: 10,
|
|
743
|
+
maxRelativeFontSizes: 3,
|
|
743
744
|
maxFontWeights: 3,
|
|
744
745
|
maxFontFamilies: 2,
|
|
745
746
|
maxRadii: 4,
|
|
@@ -780,10 +781,19 @@ function stripLayers(css, ignored) {
|
|
|
780
781
|
}
|
|
781
782
|
return out;
|
|
782
783
|
}
|
|
783
|
-
var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
784
|
+
var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
785
|
+
"inherit",
|
|
786
|
+
"initial",
|
|
787
|
+
"unset",
|
|
788
|
+
"revert",
|
|
789
|
+
"revert-layer"
|
|
790
|
+
]);
|
|
784
791
|
function atRuleBodies(css, prelude) {
|
|
785
792
|
const out = [];
|
|
786
|
-
const pattern = new RegExp(
|
|
793
|
+
const pattern = new RegExp(
|
|
794
|
+
prelude.source,
|
|
795
|
+
prelude.flags.includes("g") ? prelude.flags : `${prelude.flags}g`
|
|
796
|
+
);
|
|
787
797
|
let match;
|
|
788
798
|
while ((match = pattern.exec(css)) !== null) {
|
|
789
799
|
const start = match.index + match[0].length;
|
|
@@ -799,13 +809,64 @@ function atRuleBodies(css, prelude) {
|
|
|
799
809
|
}
|
|
800
810
|
return out;
|
|
801
811
|
}
|
|
802
|
-
function
|
|
812
|
+
function customProperties(css) {
|
|
813
|
+
const out = /* @__PURE__ */ new Map();
|
|
814
|
+
for (const match of css.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)/g)) {
|
|
815
|
+
out.set(match[1].toLowerCase(), match[2].trim().replace(/\s+/g, " "));
|
|
816
|
+
}
|
|
817
|
+
return out;
|
|
818
|
+
}
|
|
819
|
+
function unwrapShadowColor(value) {
|
|
820
|
+
return value.replace(
|
|
821
|
+
/var\(\s*--tw-shadow-color\s*,\s*([^)]*(?:\([^)]*\))?[^)]*)\)/g,
|
|
822
|
+
"$1"
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
var SINGLE_VAR = /^var\(\s*(--[\w-]+)\s*(?:,([\s\S]*))?\)$/;
|
|
826
|
+
function resolveValue(value, props, depth = 0) {
|
|
827
|
+
if (depth > 8) return null;
|
|
828
|
+
const match = SINGLE_VAR.exec(value.trim());
|
|
829
|
+
if (match === null) return value.trim();
|
|
830
|
+
const target = props.get(match[1].toLowerCase());
|
|
831
|
+
if (target !== void 0 && target !== "initial")
|
|
832
|
+
return resolveValue(target, props, depth + 1);
|
|
833
|
+
const fallback = match[2]?.trim();
|
|
834
|
+
return fallback === void 0 || fallback === "" ? null : resolveValue(fallback, props, depth + 1);
|
|
835
|
+
}
|
|
836
|
+
function isVarOnlyList(value) {
|
|
837
|
+
const parts = value.split(",");
|
|
838
|
+
return parts.length > 1 && parts.every((part) => /^\s*var\(\s*--[\w-]+\s*\)\s*$/.test(part));
|
|
839
|
+
}
|
|
840
|
+
function distinctValues(css, property, props, unresolved) {
|
|
803
841
|
const pattern = new RegExp(`(?:^|[;{\\s])${property}\\s*:\\s*([^;}]+)`, "gi");
|
|
804
842
|
const seen = /* @__PURE__ */ new Set();
|
|
805
843
|
for (const match of css.matchAll(pattern)) {
|
|
806
|
-
const
|
|
807
|
-
if (
|
|
808
|
-
|
|
844
|
+
const raw = match[1].trim().replace(/\s+/g, " ").toLowerCase();
|
|
845
|
+
if (CSS_WIDE_KEYWORDS.has(raw)) continue;
|
|
846
|
+
const value = unwrapShadowColor(raw);
|
|
847
|
+
if (isVarOnlyList(value)) continue;
|
|
848
|
+
if (!SINGLE_VAR.test(value)) {
|
|
849
|
+
seen.add(value);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
if (props === void 0) continue;
|
|
853
|
+
const resolved = resolveValue(value, props);
|
|
854
|
+
if (resolved === null) {
|
|
855
|
+
unresolved?.add(`${property}: ${value}`);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
seen.add(unwrapShadowColor(resolved).toLowerCase());
|
|
859
|
+
}
|
|
860
|
+
return [...seen];
|
|
861
|
+
}
|
|
862
|
+
function twShadowValues(css, props) {
|
|
863
|
+
const seen = /* @__PURE__ */ new Set();
|
|
864
|
+
for (const match of css.matchAll(/--tw-shadow\s*:\s*([^;}]+)/g)) {
|
|
865
|
+
const raw = match[1].trim().replace(/\s+/g, " ").toLowerCase();
|
|
866
|
+
if (raw === "0 0 #0000" || raw === "none") continue;
|
|
867
|
+
const value = SINGLE_VAR.test(raw) ? resolveValue(raw, props) : raw;
|
|
868
|
+
if (value === null) continue;
|
|
869
|
+
seen.add(unwrapShadowColor(value).toLowerCase());
|
|
809
870
|
}
|
|
810
871
|
return [...seen];
|
|
811
872
|
}
|
|
@@ -820,15 +881,28 @@ function durationsMs(css) {
|
|
|
820
881
|
}
|
|
821
882
|
function checkRestraint(input) {
|
|
822
883
|
const budget = { ...HOUSE_BUDGET, ...input.budget };
|
|
884
|
+
const whole = stripComments(input.css);
|
|
885
|
+
const props = customProperties(whole);
|
|
823
886
|
const css = stripLayers(
|
|
824
|
-
|
|
887
|
+
whole,
|
|
825
888
|
input.ignoreAtLayers ?? DEFAULT_IGNORED_LAYERS
|
|
826
889
|
);
|
|
890
|
+
const unresolved = /* @__PURE__ */ new Set();
|
|
827
891
|
const violations = [];
|
|
828
892
|
const push = (rule, severity, message, found) => {
|
|
829
893
|
violations.push({ rule, severity, message, found });
|
|
830
894
|
};
|
|
831
|
-
const
|
|
895
|
+
const allSizes = distinctValues(css, "font-size", props, unresolved);
|
|
896
|
+
const relativeSizes = allSizes.filter((v) => /^[\d.]+(em|%)$/.test(v));
|
|
897
|
+
const fontSizes = allSizes.filter((v) => !relativeSizes.includes(v));
|
|
898
|
+
if (relativeSizes.length > budget.maxRelativeFontSizes) {
|
|
899
|
+
push(
|
|
900
|
+
"relative-font-sizes",
|
|
901
|
+
"error",
|
|
902
|
+
`${relativeSizes.length} context-relative font sizes, budget ${budget.maxRelativeFontSizes}. A ratio is a relationship; more than a few and it is a second scale nobody named.`,
|
|
903
|
+
relativeSizes
|
|
904
|
+
);
|
|
905
|
+
}
|
|
832
906
|
if (fontSizes.length > budget.maxFontSizes) {
|
|
833
907
|
push(
|
|
834
908
|
"font-sizes",
|
|
@@ -837,10 +911,15 @@ function checkRestraint(input) {
|
|
|
837
911
|
fontSizes
|
|
838
912
|
);
|
|
839
913
|
}
|
|
840
|
-
const weights = distinctValues(css, "font-weight").filter(
|
|
841
|
-
|
|
842
|
-
(w) => w === "normal" || w === "bold"
|
|
914
|
+
const weights = distinctValues(css, "font-weight", props, unresolved).filter(
|
|
915
|
+
(w) => w !== "normal" && w !== "bold"
|
|
843
916
|
);
|
|
917
|
+
const namedWeights = distinctValues(
|
|
918
|
+
css,
|
|
919
|
+
"font-weight",
|
|
920
|
+
props,
|
|
921
|
+
unresolved
|
|
922
|
+
).filter((w) => w === "normal" || w === "bold");
|
|
844
923
|
const allWeights = [.../* @__PURE__ */ new Set([...weights, ...namedWeights])];
|
|
845
924
|
if (allWeights.length > budget.maxFontWeights) {
|
|
846
925
|
push(
|
|
@@ -850,7 +929,7 @@ function checkRestraint(input) {
|
|
|
850
929
|
allWeights
|
|
851
930
|
);
|
|
852
931
|
}
|
|
853
|
-
const families = distinctValues(css, "font-family").filter(
|
|
932
|
+
const families = distinctValues(css, "font-family", props, unresolved).filter(
|
|
854
933
|
(f) => !/\bmonospace\b|\bmono\b/.test(f)
|
|
855
934
|
);
|
|
856
935
|
if (families.length > budget.maxFontFamilies) {
|
|
@@ -861,11 +940,23 @@ function checkRestraint(input) {
|
|
|
861
940
|
families
|
|
862
941
|
);
|
|
863
942
|
}
|
|
864
|
-
const radii = distinctValues(css, "border-radius").filter(
|
|
943
|
+
const radii = distinctValues(css, "border-radius", props, unresolved).filter(
|
|
944
|
+
(r) => r !== "0" && r !== "0px"
|
|
945
|
+
);
|
|
865
946
|
if (radii.length > budget.maxRadii) {
|
|
866
|
-
push(
|
|
947
|
+
push(
|
|
948
|
+
"radii",
|
|
949
|
+
"error",
|
|
950
|
+
`${radii.length} corner radii, budget ${budget.maxRadii}.`,
|
|
951
|
+
radii
|
|
952
|
+
);
|
|
867
953
|
}
|
|
868
|
-
const shadows =
|
|
954
|
+
const shadows = [
|
|
955
|
+
.../* @__PURE__ */ new Set([
|
|
956
|
+
...distinctValues(css, "box-shadow", props, unresolved),
|
|
957
|
+
...twShadowValues(css, props)
|
|
958
|
+
])
|
|
959
|
+
].filter((s) => s !== "none");
|
|
869
960
|
if (shadows.length > budget.maxShadows) {
|
|
870
961
|
push(
|
|
871
962
|
"shadows",
|
|
@@ -874,6 +965,14 @@ function checkRestraint(input) {
|
|
|
874
965
|
shadows
|
|
875
966
|
);
|
|
876
967
|
}
|
|
968
|
+
if (unresolved.size > 0) {
|
|
969
|
+
push(
|
|
970
|
+
"unresolved-token",
|
|
971
|
+
"warning",
|
|
972
|
+
`${unresolved.size} declaration(s) reference a custom property that is never defined and carry no fallback. Each one applies nothing.`,
|
|
973
|
+
[...unresolved].sort()
|
|
974
|
+
);
|
|
975
|
+
}
|
|
877
976
|
const accentHexes = [
|
|
878
977
|
...css.matchAll(/--[a-z0-9-]*accent[a-z0-9-]*\s*:\s*(#[0-9a-fA-F]{3,8})/g)
|
|
879
978
|
].map((m) => m[1]);
|
|
@@ -925,7 +1024,9 @@ function checkRestraint(input) {
|
|
|
925
1024
|
[...new Set(easeIn)]
|
|
926
1025
|
);
|
|
927
1026
|
}
|
|
928
|
-
const overBudget = durationsMs(css).filter(
|
|
1027
|
+
const overBudget = durationsMs(css).filter(
|
|
1028
|
+
(d) => d.ms > budget.maxUiDurationMs
|
|
1029
|
+
);
|
|
929
1030
|
if (overBudget.length > 0) {
|
|
930
1031
|
push(
|
|
931
1032
|
"duration",
|
|
@@ -942,7 +1043,10 @@ function checkRestraint(input) {
|
|
|
942
1043
|
["scale(0)"]
|
|
943
1044
|
);
|
|
944
1045
|
}
|
|
945
|
-
const reducedMotionBlocks = atRuleBodies(
|
|
1046
|
+
const reducedMotionBlocks = atRuleBodies(
|
|
1047
|
+
css,
|
|
1048
|
+
/@media[^{]*prefers-reduced-motion:\s*reduce[^{]*\{/gi
|
|
1049
|
+
);
|
|
946
1050
|
if (reducedMotionBlocks.length === 0) {
|
|
947
1051
|
if (/@keyframes|animation\s*:|transition\s*:/.test(css)) {
|
|
948
1052
|
push(
|
|
@@ -981,6 +1085,7 @@ function checkRestraint(input) {
|
|
|
981
1085
|
violations,
|
|
982
1086
|
counts: {
|
|
983
1087
|
fontSizes: fontSizes.length,
|
|
1088
|
+
relativeFontSizes: relativeSizes.length,
|
|
984
1089
|
fontWeights: allWeights.length,
|
|
985
1090
|
fontFamilies: families.length,
|
|
986
1091
|
radii: radii.length,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@domandigital/craft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The Doman Digital house craft standard as numbers: OKLCh colour ramps that snap to existing anchors, derived semantic tokens with measured WCAG and APCA contrast, one shared motion vocabulary for CSS and JS, and a base stylesheet. Zero dependencies.",
|
|
6
6
|
"license": "Apache-2.0",
|