@domandigital/craft 0.2.0 → 0.4.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 +78 -0
- package/STANDARD.md +33 -3
- package/dist/index.cjs +179 -21
- package/dist/index.d.cts +18 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +179 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,83 @@
|
|
|
1
1
|
# @domandigital/craft
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1b2e8bf: `checkRestraint` resolves token references instead of skipping them.
|
|
8
|
+
|
|
9
|
+
The old rule skipped any value starting with `var(`, on the reasoning that a
|
|
10
|
+
token reference is the token layer doing its job rather than a new value. True
|
|
11
|
+
of a hand-written sheet. On a Tailwind v4 sheet it is close to measuring
|
|
12
|
+
nothing: `text-sm` compiles to `font-size: var(--text-sm)` and `rounded-lg` to
|
|
13
|
+
`border-radius: var(--radius-lg)`, and `shadow-[…]` emits no `box-shadow` at
|
|
14
|
+
all — it assigns `--tw-shadow` and lets one shared composite read it.
|
|
15
|
+
|
|
16
|
+
Measured on the estate this was hiding, per surface: 19 font sizes reported
|
|
17
|
+
against 28 shipped, 5 shadows against 18, 7 radii against 13. A second surface
|
|
18
|
+
reported `ok: true` on every axis and is over budget on four.
|
|
19
|
+
|
|
20
|
+
What changes:
|
|
21
|
+
|
|
22
|
+
- A value that is exactly one `var()` is resolved through the token chain to
|
|
23
|
+
the value that renders, and counted.
|
|
24
|
+
- `--tw-shadow` assignments count as shadows; Tailwind's composite plumbing
|
|
25
|
+
value does not, and neither does its `0 0 #0000` initial.
|
|
26
|
+
- `var(--tw-shadow-color, X)` is unwrapped to `X`, so the report names what a
|
|
27
|
+
reader would see.
|
|
28
|
+
- Definitions are read from the whole sheet, including ignored layers, while
|
|
29
|
+
usage is still read only from kept ones. The layer exclusion is unchanged in
|
|
30
|
+
intent and now works: a framework default nothing uses still counts for
|
|
31
|
+
nothing. Used versus unused was always the real distinction; which layer the
|
|
32
|
+
definition sat in never was.
|
|
33
|
+
- New `unresolved-token` warning for a declaration whose token is defined
|
|
34
|
+
nowhere and which carries no fallback. It applies nothing, so it is not
|
|
35
|
+
vocabulary — it is a bug, and it now says so. It found two on first contact.
|
|
36
|
+
|
|
37
|
+
**Every consumer's baseline will move**, upward, on surfaces built with a
|
|
38
|
+
utility framework. That is the correction, not a regression.
|
|
39
|
+
|
|
40
|
+
## 0.3.0
|
|
41
|
+
|
|
42
|
+
### Minor Changes
|
|
43
|
+
|
|
44
|
+
- 2075e76: `checkRestraint` measures the surface, not the framework underneath it.
|
|
45
|
+
|
|
46
|
+
Two defects, both found by running the checker against a real compiled
|
|
47
|
+
stylesheet — `apps/portal` in the Doman Digital monorepo — rather than against
|
|
48
|
+
its own fixtures.
|
|
49
|
+
|
|
50
|
+
**It rejected `EASE.inOut`, one of its own canonical curves.** The ease-in test
|
|
51
|
+
compared only the first control point (`y1 < x1`), which is true of every
|
|
52
|
+
ease-in-_out_ as well. So the standard failed its own checker on any surface
|
|
53
|
+
that adopted the token the standard recommends. Section 7's objection to ease-in
|
|
54
|
+
is about the end of the movement — a curve that accelerates into its final
|
|
55
|
+
position reads as the interface pulling away — so the test is now the end
|
|
56
|
+
tangent: rejected when the curve covers more ground per unit time arriving than
|
|
57
|
+
it averaged. `EASE.out`, `EASE.inOut`, `EASE.drawer`, `EASE.reveal`, Tailwind's
|
|
58
|
+
default and `linear` all pass; the `ease-in` keyword's curve, the portal's old
|
|
59
|
+
`--ease-exit`, and a back-loaded custom curve are all still rejected.
|
|
60
|
+
|
|
61
|
+
**It counted the framework's reset and theme as house vocabulary.** Tailwind v4
|
|
62
|
+
alone contributes three font sizes nobody chose (`small` at 80%, `sub`/`sup` at
|
|
63
|
+
75%, `code` at 1em) and two timing functions, which was enough to report a
|
|
64
|
+
surface as over its font-size budget for values its author never wrote.
|
|
65
|
+
`@layer theme` and `@layer base` are now excluded by default via a new
|
|
66
|
+
`ignoreAtLayers` option; pass `[]` to count everything, as before.
|
|
67
|
+
|
|
68
|
+
**Its reduced-motion check read the rest of the file, not the block.** It sliced
|
|
69
|
+
from the first `@media (prefers-reduced-motion: reduce)` to the end of the sheet,
|
|
70
|
+
so every rule after that point counted as being inside it. On a real sheet that
|
|
71
|
+
meant `[data-craft-lcp] { animation: none }` — this package's own section 7 rule,
|
|
72
|
+
correctly outside any media query — was reported as a section 8 violation. The
|
|
73
|
+
scan is now brace-matched to each block's own body, and reads every such block
|
|
74
|
+
rather than only the first.
|
|
75
|
+
|
|
76
|
+
**CSS-wide keywords are no longer counted as vocabulary.** `inherit` and
|
|
77
|
+
`initial` were already excluded; `unset`, `revert` and `revert-layer` were not,
|
|
78
|
+
and `revert` appears in compiled output. A declaration that defers to the cascade
|
|
79
|
+
names no value, so it is not a step of anything.
|
|
80
|
+
|
|
3
81
|
## 0.2.0
|
|
4
82
|
|
|
5
83
|
### Minor Changes
|
package/STANDARD.md
CHANGED
|
@@ -83,7 +83,10 @@ needs. Set once on a shell via `[data-density]`, inherited below.
|
|
|
83
83
|
- **Transform and opacity only.** Everything else cannot be composited.
|
|
84
84
|
- **Exits run at 0.75× their entrance.** Arriving content is asking to be read;
|
|
85
85
|
leaving content lingering reads as lag.
|
|
86
|
-
- **No ease-in for UI, ever.** It accelerates away from the user.
|
|
86
|
+
- **No ease-in for UI, ever.** It accelerates away from the user. The objection
|
|
87
|
+
is about the *end* of the movement, not the start, which is why ease-in-out is
|
|
88
|
+
fine: a curve is rejected when its end tangent is steeper than the diagonal,
|
|
89
|
+
meaning it covers more ground per unit time arriving than it averaged.
|
|
87
90
|
- **UI durations ≤ 300ms.** Drawers and one gated reveal may exceed it.
|
|
88
91
|
- **Don't animate what fires 100×/day**, and never animate a keyboard-triggered
|
|
89
92
|
interaction — the user is moving faster than the animation.
|
|
@@ -96,7 +99,7 @@ needs. Set once on a shell via `[data-density]`, inherited below.
|
|
|
96
99
|
`@media (prefers-reduced-motion: reduce)` sets `animation-duration` and
|
|
97
100
|
`transition-duration` to **0.01ms** and `animation-iteration-count` to 1.
|
|
98
101
|
|
|
99
|
-
**Never `animation: none
|
|
102
|
+
**Never `animation: none` inside the block.** That drops the `forwards` fill along with the
|
|
100
103
|
animation, so a forwards-filled entrance never applies its end state and the
|
|
101
104
|
element keeps `opacity: 0` from its base rule — permanently invisible, for
|
|
102
105
|
exactly the users who asked for less motion. This shipped.
|
|
@@ -114,7 +117,34 @@ Differentiation guards pass anything that differs from its siblings, however
|
|
|
114
117
|
ugly. What they cannot see is accumulation — eighteen font sizes, six shadows,
|
|
115
118
|
four competing accents, each added reasonably, together reading as noise.
|
|
116
119
|
|
|
117
|
-
|
|
120
|
+
The budget counts the vocabulary a surface **chose**, and the whole difficulty
|
|
121
|
+
is telling that apart from what the framework brought.
|
|
122
|
+
|
|
123
|
+
The first rule drew the line at the layer: `@layer theme` and `@layer base` were
|
|
124
|
+
excluded, because counting them reported a surface as over budget for values its
|
|
125
|
+
author never wrote. Right about the noise, wrong about the boundary. Tailwind v4
|
|
126
|
+
compiles `text-sm` to `font-size: var(--text-sm)` and `rounded-lg` to
|
|
127
|
+
`border-radius: var(--radius-lg)`, so with the definitions excluded *and* token
|
|
128
|
+
references skipped, a surface using `text-sm` seven hundred times reported it
|
|
129
|
+
zero times. One estate measured 19 font sizes and shipped 28; 5 shadows and
|
|
130
|
+
shipped 18. A count wrong in the under-reporting direction is worse than the
|
|
131
|
+
over-reporting kind it replaced: the first buries real findings in noise, the
|
|
132
|
+
second reports a pass.
|
|
133
|
+
|
|
134
|
+
The line is **used versus unused**, which is not a property of the layer:
|
|
135
|
+
|
|
136
|
+
- References are **resolved**, not skipped — through the token chain, to the
|
|
137
|
+
value that renders.
|
|
138
|
+
- A definition is only ever reached from a declaration in a kept layer, so a
|
|
139
|
+
framework default nothing uses still counts for nothing.
|
|
140
|
+
- `--tw-shadow` assignments count as shadows. `shadow-[…]` emits no literal
|
|
141
|
+
`box-shadow` at all; it assigns that property and lets one shared composite
|
|
142
|
+
read it.
|
|
143
|
+
- A reference that resolves to nothing is **not** vocabulary. It is a
|
|
144
|
+
declaration that silently applies nothing, and it is reported as its own
|
|
145
|
+
warning.
|
|
146
|
+
|
|
147
|
+
*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.*
|
|
118
148
|
|
|
119
149
|
## 10. What is deliberately not a rule
|
|
120
150
|
|
package/dist/index.cjs
CHANGED
|
@@ -819,16 +819,125 @@ var HOUSE_BUDGET = {
|
|
|
819
819
|
accentHueClusterDeg: 15,
|
|
820
820
|
maxUiDurationMs: 300
|
|
821
821
|
};
|
|
822
|
+
var DEFAULT_IGNORED_LAYERS = ["theme", "base"];
|
|
822
823
|
function stripComments(css) {
|
|
823
824
|
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
824
825
|
}
|
|
825
|
-
function
|
|
826
|
+
function stripLayers(css, ignored) {
|
|
827
|
+
if (ignored.length === 0) return css;
|
|
828
|
+
const names = new Set(ignored.map((n) => n.trim().toLowerCase()));
|
|
829
|
+
let out = "";
|
|
830
|
+
let i = 0;
|
|
831
|
+
const pattern = /@layer\s+([a-zA-Z0-9_-]+)\s*\{/gi;
|
|
832
|
+
while (i < css.length) {
|
|
833
|
+
pattern.lastIndex = i;
|
|
834
|
+
const match = pattern.exec(css);
|
|
835
|
+
if (match === null) {
|
|
836
|
+
out += css.slice(i);
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
out += css.slice(i, match.index);
|
|
840
|
+
const bodyStart = match.index + match[0].length;
|
|
841
|
+
let depth = 1;
|
|
842
|
+
let j = bodyStart;
|
|
843
|
+
while (j < css.length && depth > 0) {
|
|
844
|
+
if (css[j] === "{") depth += 1;
|
|
845
|
+
else if (css[j] === "}") depth -= 1;
|
|
846
|
+
j += 1;
|
|
847
|
+
}
|
|
848
|
+
if (!names.has(match[1].toLowerCase())) {
|
|
849
|
+
out += stripLayers(css.slice(bodyStart, j - 1), ignored);
|
|
850
|
+
}
|
|
851
|
+
i = j;
|
|
852
|
+
}
|
|
853
|
+
return out;
|
|
854
|
+
}
|
|
855
|
+
var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
856
|
+
"inherit",
|
|
857
|
+
"initial",
|
|
858
|
+
"unset",
|
|
859
|
+
"revert",
|
|
860
|
+
"revert-layer"
|
|
861
|
+
]);
|
|
862
|
+
function atRuleBodies(css, prelude) {
|
|
863
|
+
const out = [];
|
|
864
|
+
const pattern = new RegExp(
|
|
865
|
+
prelude.source,
|
|
866
|
+
prelude.flags.includes("g") ? prelude.flags : `${prelude.flags}g`
|
|
867
|
+
);
|
|
868
|
+
let match;
|
|
869
|
+
while ((match = pattern.exec(css)) !== null) {
|
|
870
|
+
const start = match.index + match[0].length;
|
|
871
|
+
let depth = 1;
|
|
872
|
+
let i = start;
|
|
873
|
+
while (i < css.length && depth > 0) {
|
|
874
|
+
if (css[i] === "{") depth += 1;
|
|
875
|
+
else if (css[i] === "}") depth -= 1;
|
|
876
|
+
i += 1;
|
|
877
|
+
}
|
|
878
|
+
out.push(css.slice(start, i - 1));
|
|
879
|
+
pattern.lastIndex = i;
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
function customProperties(css) {
|
|
884
|
+
const out = /* @__PURE__ */ new Map();
|
|
885
|
+
for (const match of css.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)/g)) {
|
|
886
|
+
out.set(match[1].toLowerCase(), match[2].trim().replace(/\s+/g, " "));
|
|
887
|
+
}
|
|
888
|
+
return out;
|
|
889
|
+
}
|
|
890
|
+
function unwrapShadowColor(value) {
|
|
891
|
+
return value.replace(
|
|
892
|
+
/var\(\s*--tw-shadow-color\s*,\s*([^)]*(?:\([^)]*\))?[^)]*)\)/g,
|
|
893
|
+
"$1"
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
var SINGLE_VAR = /^var\(\s*(--[\w-]+)\s*(?:,([\s\S]*))?\)$/;
|
|
897
|
+
function resolveValue(value, props, depth = 0) {
|
|
898
|
+
if (depth > 8) return null;
|
|
899
|
+
const match = SINGLE_VAR.exec(value.trim());
|
|
900
|
+
if (match === null) return value.trim();
|
|
901
|
+
const target = props.get(match[1].toLowerCase());
|
|
902
|
+
if (target !== void 0 && target !== "initial")
|
|
903
|
+
return resolveValue(target, props, depth + 1);
|
|
904
|
+
const fallback = match[2]?.trim();
|
|
905
|
+
return fallback === void 0 || fallback === "" ? null : resolveValue(fallback, props, depth + 1);
|
|
906
|
+
}
|
|
907
|
+
function isVarOnlyList(value) {
|
|
908
|
+
const parts = value.split(",");
|
|
909
|
+
return parts.length > 1 && parts.every((part) => /^\s*var\(\s*--[\w-]+\s*\)\s*$/.test(part));
|
|
910
|
+
}
|
|
911
|
+
function distinctValues(css, property, props, unresolved) {
|
|
826
912
|
const pattern = new RegExp(`(?:^|[;{\\s])${property}\\s*:\\s*([^;}]+)`, "gi");
|
|
827
913
|
const seen = /* @__PURE__ */ new Set();
|
|
828
914
|
for (const match of css.matchAll(pattern)) {
|
|
829
|
-
const
|
|
830
|
-
if (
|
|
831
|
-
|
|
915
|
+
const raw = match[1].trim().replace(/\s+/g, " ").toLowerCase();
|
|
916
|
+
if (CSS_WIDE_KEYWORDS.has(raw)) continue;
|
|
917
|
+
const value = unwrapShadowColor(raw);
|
|
918
|
+
if (isVarOnlyList(value)) continue;
|
|
919
|
+
if (!SINGLE_VAR.test(value)) {
|
|
920
|
+
seen.add(value);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (props === void 0) continue;
|
|
924
|
+
const resolved = resolveValue(value, props);
|
|
925
|
+
if (resolved === null) {
|
|
926
|
+
unresolved?.add(`${property}: ${value}`);
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
seen.add(unwrapShadowColor(resolved).toLowerCase());
|
|
930
|
+
}
|
|
931
|
+
return [...seen];
|
|
932
|
+
}
|
|
933
|
+
function twShadowValues(css, props) {
|
|
934
|
+
const seen = /* @__PURE__ */ new Set();
|
|
935
|
+
for (const match of css.matchAll(/--tw-shadow\s*:\s*([^;}]+)/g)) {
|
|
936
|
+
const raw = match[1].trim().replace(/\s+/g, " ").toLowerCase();
|
|
937
|
+
if (raw === "0 0 #0000" || raw === "none") continue;
|
|
938
|
+
const value = SINGLE_VAR.test(raw) ? resolveValue(raw, props) : raw;
|
|
939
|
+
if (value === null) continue;
|
|
940
|
+
seen.add(unwrapShadowColor(value).toLowerCase());
|
|
832
941
|
}
|
|
833
942
|
return [...seen];
|
|
834
943
|
}
|
|
@@ -843,12 +952,18 @@ function durationsMs(css) {
|
|
|
843
952
|
}
|
|
844
953
|
function checkRestraint(input) {
|
|
845
954
|
const budget = { ...HOUSE_BUDGET, ...input.budget };
|
|
846
|
-
const
|
|
955
|
+
const whole = stripComments(input.css);
|
|
956
|
+
const props = customProperties(whole);
|
|
957
|
+
const css = stripLayers(
|
|
958
|
+
whole,
|
|
959
|
+
input.ignoreAtLayers ?? DEFAULT_IGNORED_LAYERS
|
|
960
|
+
);
|
|
961
|
+
const unresolved = /* @__PURE__ */ new Set();
|
|
847
962
|
const violations = [];
|
|
848
963
|
const push = (rule, severity, message, found) => {
|
|
849
964
|
violations.push({ rule, severity, message, found });
|
|
850
965
|
};
|
|
851
|
-
const fontSizes = distinctValues(css, "font-size");
|
|
966
|
+
const fontSizes = distinctValues(css, "font-size", props, unresolved);
|
|
852
967
|
if (fontSizes.length > budget.maxFontSizes) {
|
|
853
968
|
push(
|
|
854
969
|
"font-sizes",
|
|
@@ -857,10 +972,15 @@ function checkRestraint(input) {
|
|
|
857
972
|
fontSizes
|
|
858
973
|
);
|
|
859
974
|
}
|
|
860
|
-
const weights = distinctValues(css, "font-weight").filter(
|
|
861
|
-
|
|
862
|
-
(w) => w === "normal" || w === "bold"
|
|
975
|
+
const weights = distinctValues(css, "font-weight", props, unresolved).filter(
|
|
976
|
+
(w) => w !== "normal" && w !== "bold"
|
|
863
977
|
);
|
|
978
|
+
const namedWeights = distinctValues(
|
|
979
|
+
css,
|
|
980
|
+
"font-weight",
|
|
981
|
+
props,
|
|
982
|
+
unresolved
|
|
983
|
+
).filter((w) => w === "normal" || w === "bold");
|
|
864
984
|
const allWeights = [.../* @__PURE__ */ new Set([...weights, ...namedWeights])];
|
|
865
985
|
if (allWeights.length > budget.maxFontWeights) {
|
|
866
986
|
push(
|
|
@@ -870,7 +990,7 @@ function checkRestraint(input) {
|
|
|
870
990
|
allWeights
|
|
871
991
|
);
|
|
872
992
|
}
|
|
873
|
-
const families = distinctValues(css, "font-family").filter(
|
|
993
|
+
const families = distinctValues(css, "font-family", props, unresolved).filter(
|
|
874
994
|
(f) => !/\bmonospace\b|\bmono\b/.test(f)
|
|
875
995
|
);
|
|
876
996
|
if (families.length > budget.maxFontFamilies) {
|
|
@@ -881,11 +1001,23 @@ function checkRestraint(input) {
|
|
|
881
1001
|
families
|
|
882
1002
|
);
|
|
883
1003
|
}
|
|
884
|
-
const radii = distinctValues(css, "border-radius").filter(
|
|
1004
|
+
const radii = distinctValues(css, "border-radius", props, unresolved).filter(
|
|
1005
|
+
(r) => r !== "0" && r !== "0px"
|
|
1006
|
+
);
|
|
885
1007
|
if (radii.length > budget.maxRadii) {
|
|
886
|
-
push(
|
|
1008
|
+
push(
|
|
1009
|
+
"radii",
|
|
1010
|
+
"error",
|
|
1011
|
+
`${radii.length} corner radii, budget ${budget.maxRadii}.`,
|
|
1012
|
+
radii
|
|
1013
|
+
);
|
|
887
1014
|
}
|
|
888
|
-
const shadows =
|
|
1015
|
+
const shadows = [
|
|
1016
|
+
.../* @__PURE__ */ new Set([
|
|
1017
|
+
...distinctValues(css, "box-shadow", props, unresolved),
|
|
1018
|
+
...twShadowValues(css, props)
|
|
1019
|
+
])
|
|
1020
|
+
].filter((s) => s !== "none");
|
|
889
1021
|
if (shadows.length > budget.maxShadows) {
|
|
890
1022
|
push(
|
|
891
1023
|
"shadows",
|
|
@@ -894,6 +1026,14 @@ function checkRestraint(input) {
|
|
|
894
1026
|
shadows
|
|
895
1027
|
);
|
|
896
1028
|
}
|
|
1029
|
+
if (unresolved.size > 0) {
|
|
1030
|
+
push(
|
|
1031
|
+
"unresolved-token",
|
|
1032
|
+
"warning",
|
|
1033
|
+
`${unresolved.size} declaration(s) reference a custom property that is never defined and carry no fallback. Each one applies nothing.`,
|
|
1034
|
+
[...unresolved].sort()
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
897
1037
|
const accentHexes = [
|
|
898
1038
|
...css.matchAll(/--[a-z0-9-]*accent[a-z0-9-]*\s*:\s*(#[0-9a-fA-F]{3,8})/g)
|
|
899
1039
|
].map((m) => m[1]);
|
|
@@ -928,12 +1068,14 @@ function checkRestraint(input) {
|
|
|
928
1068
|
}
|
|
929
1069
|
const easeIn = [
|
|
930
1070
|
...css.matchAll(/(?:transition|animation)[^;}]*\b(ease-in)\b(?!-out)/gi),
|
|
931
|
-
...css.matchAll(
|
|
1071
|
+
...css.matchAll(
|
|
1072
|
+
/cubic-bezier\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/gi
|
|
1073
|
+
)
|
|
932
1074
|
].map((m) => {
|
|
933
1075
|
if (m[1] === "ease-in") return "ease-in";
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
return
|
|
1076
|
+
const p = [m[1], m[2], m[3], m[4]].map(Number);
|
|
1077
|
+
if (!p.every((n) => Number.isFinite(n))) return null;
|
|
1078
|
+
return acceleratesIntoTheEnd(p[0], p[1], p[2], p[3]) ? m[0] : null;
|
|
937
1079
|
}).filter((v) => v !== null);
|
|
938
1080
|
if (easeIn.length > 0) {
|
|
939
1081
|
push(
|
|
@@ -943,7 +1085,9 @@ function checkRestraint(input) {
|
|
|
943
1085
|
[...new Set(easeIn)]
|
|
944
1086
|
);
|
|
945
1087
|
}
|
|
946
|
-
const overBudget = durationsMs(css).filter(
|
|
1088
|
+
const overBudget = durationsMs(css).filter(
|
|
1089
|
+
(d) => d.ms > budget.maxUiDurationMs
|
|
1090
|
+
);
|
|
947
1091
|
if (overBudget.length > 0) {
|
|
948
1092
|
push(
|
|
949
1093
|
"duration",
|
|
@@ -960,8 +1104,11 @@ function checkRestraint(input) {
|
|
|
960
1104
|
["scale(0)"]
|
|
961
1105
|
);
|
|
962
1106
|
}
|
|
963
|
-
const
|
|
964
|
-
|
|
1107
|
+
const reducedMotionBlocks = atRuleBodies(
|
|
1108
|
+
css,
|
|
1109
|
+
/@media[^{]*prefers-reduced-motion:\s*reduce[^{]*\{/gi
|
|
1110
|
+
);
|
|
1111
|
+
if (reducedMotionBlocks.length === 0) {
|
|
965
1112
|
if (/@keyframes|animation\s*:|transition\s*:/.test(css)) {
|
|
966
1113
|
push(
|
|
967
1114
|
"reduced-motion-missing",
|
|
@@ -971,7 +1118,7 @@ function checkRestraint(input) {
|
|
|
971
1118
|
);
|
|
972
1119
|
}
|
|
973
1120
|
} else {
|
|
974
|
-
const block =
|
|
1121
|
+
const block = reducedMotionBlocks.join("\n");
|
|
975
1122
|
if (/animation\s*:\s*none/i.test(block)) {
|
|
976
1123
|
push(
|
|
977
1124
|
"reduced-motion-animation-none",
|
|
@@ -1007,6 +1154,17 @@ function checkRestraint(input) {
|
|
|
1007
1154
|
}
|
|
1008
1155
|
};
|
|
1009
1156
|
}
|
|
1157
|
+
function acceleratesIntoTheEnd(x1, y1, x2, y2) {
|
|
1158
|
+
const EPSILON = 1e-6;
|
|
1159
|
+
let dx = 1 - x2;
|
|
1160
|
+
let dy = 1 - y2;
|
|
1161
|
+
if (Math.abs(dx) < EPSILON && Math.abs(dy) < EPSILON) {
|
|
1162
|
+
dx = 1 - x1;
|
|
1163
|
+
dy = 1 - y1;
|
|
1164
|
+
}
|
|
1165
|
+
if (Math.abs(dx) < EPSILON) return Math.abs(dy) > EPSILON;
|
|
1166
|
+
return dy / dx > 1 + EPSILON;
|
|
1167
|
+
}
|
|
1010
1168
|
function hueSpread(hues) {
|
|
1011
1169
|
const sorted = [...hues].sort((a, b) => a - b);
|
|
1012
1170
|
let widestGap = 0;
|
package/dist/index.d.cts
CHANGED
|
@@ -631,6 +631,24 @@ interface CheckRestraintInput {
|
|
|
631
631
|
/** Concatenated markup or component source, for class-level rules. */
|
|
632
632
|
markup?: string;
|
|
633
633
|
budget?: Partial<RestraintBudget>;
|
|
634
|
+
/**
|
|
635
|
+
* Cascade layers to exclude before counting, by name.
|
|
636
|
+
*
|
|
637
|
+
* A restraint budget measures the vocabulary a surface *chose*. A compiled
|
|
638
|
+
* stylesheet also carries the framework's reset and theme, and counting those
|
|
639
|
+
* makes the report wrong in the one direction that matters: it reports a
|
|
640
|
+
* surface as over budget for values its author never wrote, so the real
|
|
641
|
+
* findings get triaged away with the noise. Measured on this estate, Tailwind
|
|
642
|
+
* v4 contributes three font sizes nobody chose (`small` at 80%, `sub`/`sup` at
|
|
643
|
+
* 75%) and two timing functions (`--default-transition-timing-function`,
|
|
644
|
+
* `--animate-pulse`) from `@layer theme` and `@layer base` alone.
|
|
645
|
+
*
|
|
646
|
+
* The default is those two names, which is the convention every layered
|
|
647
|
+
* framework follows and the one Tailwind enforces. A rule placed in the reset
|
|
648
|
+
* layer is a reset by the author's own declaration; put it outside the layer
|
|
649
|
+
* to have it counted as vocabulary. Pass `[]` to count everything.
|
|
650
|
+
*/
|
|
651
|
+
ignoreAtLayers?: string[];
|
|
634
652
|
}
|
|
635
653
|
/**
|
|
636
654
|
* Check a stylesheet against the restraint budget.
|
package/dist/index.d.ts
CHANGED
|
@@ -631,6 +631,24 @@ interface CheckRestraintInput {
|
|
|
631
631
|
/** Concatenated markup or component source, for class-level rules. */
|
|
632
632
|
markup?: string;
|
|
633
633
|
budget?: Partial<RestraintBudget>;
|
|
634
|
+
/**
|
|
635
|
+
* Cascade layers to exclude before counting, by name.
|
|
636
|
+
*
|
|
637
|
+
* A restraint budget measures the vocabulary a surface *chose*. A compiled
|
|
638
|
+
* stylesheet also carries the framework's reset and theme, and counting those
|
|
639
|
+
* makes the report wrong in the one direction that matters: it reports a
|
|
640
|
+
* surface as over budget for values its author never wrote, so the real
|
|
641
|
+
* findings get triaged away with the noise. Measured on this estate, Tailwind
|
|
642
|
+
* v4 contributes three font sizes nobody chose (`small` at 80%, `sub`/`sup` at
|
|
643
|
+
* 75%) and two timing functions (`--default-transition-timing-function`,
|
|
644
|
+
* `--animate-pulse`) from `@layer theme` and `@layer base` alone.
|
|
645
|
+
*
|
|
646
|
+
* The default is those two names, which is the convention every layered
|
|
647
|
+
* framework follows and the one Tailwind enforces. A rule placed in the reset
|
|
648
|
+
* layer is a reset by the author's own declaration; put it outside the layer
|
|
649
|
+
* to have it counted as vocabulary. Pass `[]` to count everything.
|
|
650
|
+
*/
|
|
651
|
+
ignoreAtLayers?: string[];
|
|
634
652
|
}
|
|
635
653
|
/**
|
|
636
654
|
* Check a stylesheet against the restraint budget.
|
package/dist/index.js
CHANGED
|
@@ -747,16 +747,125 @@ var HOUSE_BUDGET = {
|
|
|
747
747
|
accentHueClusterDeg: 15,
|
|
748
748
|
maxUiDurationMs: 300
|
|
749
749
|
};
|
|
750
|
+
var DEFAULT_IGNORED_LAYERS = ["theme", "base"];
|
|
750
751
|
function stripComments(css) {
|
|
751
752
|
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
752
753
|
}
|
|
753
|
-
function
|
|
754
|
+
function stripLayers(css, ignored) {
|
|
755
|
+
if (ignored.length === 0) return css;
|
|
756
|
+
const names = new Set(ignored.map((n) => n.trim().toLowerCase()));
|
|
757
|
+
let out = "";
|
|
758
|
+
let i = 0;
|
|
759
|
+
const pattern = /@layer\s+([a-zA-Z0-9_-]+)\s*\{/gi;
|
|
760
|
+
while (i < css.length) {
|
|
761
|
+
pattern.lastIndex = i;
|
|
762
|
+
const match = pattern.exec(css);
|
|
763
|
+
if (match === null) {
|
|
764
|
+
out += css.slice(i);
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
out += css.slice(i, match.index);
|
|
768
|
+
const bodyStart = match.index + match[0].length;
|
|
769
|
+
let depth = 1;
|
|
770
|
+
let j = bodyStart;
|
|
771
|
+
while (j < css.length && depth > 0) {
|
|
772
|
+
if (css[j] === "{") depth += 1;
|
|
773
|
+
else if (css[j] === "}") depth -= 1;
|
|
774
|
+
j += 1;
|
|
775
|
+
}
|
|
776
|
+
if (!names.has(match[1].toLowerCase())) {
|
|
777
|
+
out += stripLayers(css.slice(bodyStart, j - 1), ignored);
|
|
778
|
+
}
|
|
779
|
+
i = j;
|
|
780
|
+
}
|
|
781
|
+
return out;
|
|
782
|
+
}
|
|
783
|
+
var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
784
|
+
"inherit",
|
|
785
|
+
"initial",
|
|
786
|
+
"unset",
|
|
787
|
+
"revert",
|
|
788
|
+
"revert-layer"
|
|
789
|
+
]);
|
|
790
|
+
function atRuleBodies(css, prelude) {
|
|
791
|
+
const out = [];
|
|
792
|
+
const pattern = new RegExp(
|
|
793
|
+
prelude.source,
|
|
794
|
+
prelude.flags.includes("g") ? prelude.flags : `${prelude.flags}g`
|
|
795
|
+
);
|
|
796
|
+
let match;
|
|
797
|
+
while ((match = pattern.exec(css)) !== null) {
|
|
798
|
+
const start = match.index + match[0].length;
|
|
799
|
+
let depth = 1;
|
|
800
|
+
let i = start;
|
|
801
|
+
while (i < css.length && depth > 0) {
|
|
802
|
+
if (css[i] === "{") depth += 1;
|
|
803
|
+
else if (css[i] === "}") depth -= 1;
|
|
804
|
+
i += 1;
|
|
805
|
+
}
|
|
806
|
+
out.push(css.slice(start, i - 1));
|
|
807
|
+
pattern.lastIndex = i;
|
|
808
|
+
}
|
|
809
|
+
return out;
|
|
810
|
+
}
|
|
811
|
+
function customProperties(css) {
|
|
812
|
+
const out = /* @__PURE__ */ new Map();
|
|
813
|
+
for (const match of css.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)/g)) {
|
|
814
|
+
out.set(match[1].toLowerCase(), match[2].trim().replace(/\s+/g, " "));
|
|
815
|
+
}
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
function unwrapShadowColor(value) {
|
|
819
|
+
return value.replace(
|
|
820
|
+
/var\(\s*--tw-shadow-color\s*,\s*([^)]*(?:\([^)]*\))?[^)]*)\)/g,
|
|
821
|
+
"$1"
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
var SINGLE_VAR = /^var\(\s*(--[\w-]+)\s*(?:,([\s\S]*))?\)$/;
|
|
825
|
+
function resolveValue(value, props, depth = 0) {
|
|
826
|
+
if (depth > 8) return null;
|
|
827
|
+
const match = SINGLE_VAR.exec(value.trim());
|
|
828
|
+
if (match === null) return value.trim();
|
|
829
|
+
const target = props.get(match[1].toLowerCase());
|
|
830
|
+
if (target !== void 0 && target !== "initial")
|
|
831
|
+
return resolveValue(target, props, depth + 1);
|
|
832
|
+
const fallback = match[2]?.trim();
|
|
833
|
+
return fallback === void 0 || fallback === "" ? null : resolveValue(fallback, props, depth + 1);
|
|
834
|
+
}
|
|
835
|
+
function isVarOnlyList(value) {
|
|
836
|
+
const parts = value.split(",");
|
|
837
|
+
return parts.length > 1 && parts.every((part) => /^\s*var\(\s*--[\w-]+\s*\)\s*$/.test(part));
|
|
838
|
+
}
|
|
839
|
+
function distinctValues(css, property, props, unresolved) {
|
|
754
840
|
const pattern = new RegExp(`(?:^|[;{\\s])${property}\\s*:\\s*([^;}]+)`, "gi");
|
|
755
841
|
const seen = /* @__PURE__ */ new Set();
|
|
756
842
|
for (const match of css.matchAll(pattern)) {
|
|
757
|
-
const
|
|
758
|
-
if (
|
|
759
|
-
|
|
843
|
+
const raw = match[1].trim().replace(/\s+/g, " ").toLowerCase();
|
|
844
|
+
if (CSS_WIDE_KEYWORDS.has(raw)) continue;
|
|
845
|
+
const value = unwrapShadowColor(raw);
|
|
846
|
+
if (isVarOnlyList(value)) continue;
|
|
847
|
+
if (!SINGLE_VAR.test(value)) {
|
|
848
|
+
seen.add(value);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
if (props === void 0) continue;
|
|
852
|
+
const resolved = resolveValue(value, props);
|
|
853
|
+
if (resolved === null) {
|
|
854
|
+
unresolved?.add(`${property}: ${value}`);
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
seen.add(unwrapShadowColor(resolved).toLowerCase());
|
|
858
|
+
}
|
|
859
|
+
return [...seen];
|
|
860
|
+
}
|
|
861
|
+
function twShadowValues(css, props) {
|
|
862
|
+
const seen = /* @__PURE__ */ new Set();
|
|
863
|
+
for (const match of css.matchAll(/--tw-shadow\s*:\s*([^;}]+)/g)) {
|
|
864
|
+
const raw = match[1].trim().replace(/\s+/g, " ").toLowerCase();
|
|
865
|
+
if (raw === "0 0 #0000" || raw === "none") continue;
|
|
866
|
+
const value = SINGLE_VAR.test(raw) ? resolveValue(raw, props) : raw;
|
|
867
|
+
if (value === null) continue;
|
|
868
|
+
seen.add(unwrapShadowColor(value).toLowerCase());
|
|
760
869
|
}
|
|
761
870
|
return [...seen];
|
|
762
871
|
}
|
|
@@ -771,12 +880,18 @@ function durationsMs(css) {
|
|
|
771
880
|
}
|
|
772
881
|
function checkRestraint(input) {
|
|
773
882
|
const budget = { ...HOUSE_BUDGET, ...input.budget };
|
|
774
|
-
const
|
|
883
|
+
const whole = stripComments(input.css);
|
|
884
|
+
const props = customProperties(whole);
|
|
885
|
+
const css = stripLayers(
|
|
886
|
+
whole,
|
|
887
|
+
input.ignoreAtLayers ?? DEFAULT_IGNORED_LAYERS
|
|
888
|
+
);
|
|
889
|
+
const unresolved = /* @__PURE__ */ new Set();
|
|
775
890
|
const violations = [];
|
|
776
891
|
const push = (rule, severity, message, found) => {
|
|
777
892
|
violations.push({ rule, severity, message, found });
|
|
778
893
|
};
|
|
779
|
-
const fontSizes = distinctValues(css, "font-size");
|
|
894
|
+
const fontSizes = distinctValues(css, "font-size", props, unresolved);
|
|
780
895
|
if (fontSizes.length > budget.maxFontSizes) {
|
|
781
896
|
push(
|
|
782
897
|
"font-sizes",
|
|
@@ -785,10 +900,15 @@ function checkRestraint(input) {
|
|
|
785
900
|
fontSizes
|
|
786
901
|
);
|
|
787
902
|
}
|
|
788
|
-
const weights = distinctValues(css, "font-weight").filter(
|
|
789
|
-
|
|
790
|
-
(w) => w === "normal" || w === "bold"
|
|
903
|
+
const weights = distinctValues(css, "font-weight", props, unresolved).filter(
|
|
904
|
+
(w) => w !== "normal" && w !== "bold"
|
|
791
905
|
);
|
|
906
|
+
const namedWeights = distinctValues(
|
|
907
|
+
css,
|
|
908
|
+
"font-weight",
|
|
909
|
+
props,
|
|
910
|
+
unresolved
|
|
911
|
+
).filter((w) => w === "normal" || w === "bold");
|
|
792
912
|
const allWeights = [.../* @__PURE__ */ new Set([...weights, ...namedWeights])];
|
|
793
913
|
if (allWeights.length > budget.maxFontWeights) {
|
|
794
914
|
push(
|
|
@@ -798,7 +918,7 @@ function checkRestraint(input) {
|
|
|
798
918
|
allWeights
|
|
799
919
|
);
|
|
800
920
|
}
|
|
801
|
-
const families = distinctValues(css, "font-family").filter(
|
|
921
|
+
const families = distinctValues(css, "font-family", props, unresolved).filter(
|
|
802
922
|
(f) => !/\bmonospace\b|\bmono\b/.test(f)
|
|
803
923
|
);
|
|
804
924
|
if (families.length > budget.maxFontFamilies) {
|
|
@@ -809,11 +929,23 @@ function checkRestraint(input) {
|
|
|
809
929
|
families
|
|
810
930
|
);
|
|
811
931
|
}
|
|
812
|
-
const radii = distinctValues(css, "border-radius").filter(
|
|
932
|
+
const radii = distinctValues(css, "border-radius", props, unresolved).filter(
|
|
933
|
+
(r) => r !== "0" && r !== "0px"
|
|
934
|
+
);
|
|
813
935
|
if (radii.length > budget.maxRadii) {
|
|
814
|
-
push(
|
|
936
|
+
push(
|
|
937
|
+
"radii",
|
|
938
|
+
"error",
|
|
939
|
+
`${radii.length} corner radii, budget ${budget.maxRadii}.`,
|
|
940
|
+
radii
|
|
941
|
+
);
|
|
815
942
|
}
|
|
816
|
-
const shadows =
|
|
943
|
+
const shadows = [
|
|
944
|
+
.../* @__PURE__ */ new Set([
|
|
945
|
+
...distinctValues(css, "box-shadow", props, unresolved),
|
|
946
|
+
...twShadowValues(css, props)
|
|
947
|
+
])
|
|
948
|
+
].filter((s) => s !== "none");
|
|
817
949
|
if (shadows.length > budget.maxShadows) {
|
|
818
950
|
push(
|
|
819
951
|
"shadows",
|
|
@@ -822,6 +954,14 @@ function checkRestraint(input) {
|
|
|
822
954
|
shadows
|
|
823
955
|
);
|
|
824
956
|
}
|
|
957
|
+
if (unresolved.size > 0) {
|
|
958
|
+
push(
|
|
959
|
+
"unresolved-token",
|
|
960
|
+
"warning",
|
|
961
|
+
`${unresolved.size} declaration(s) reference a custom property that is never defined and carry no fallback. Each one applies nothing.`,
|
|
962
|
+
[...unresolved].sort()
|
|
963
|
+
);
|
|
964
|
+
}
|
|
825
965
|
const accentHexes = [
|
|
826
966
|
...css.matchAll(/--[a-z0-9-]*accent[a-z0-9-]*\s*:\s*(#[0-9a-fA-F]{3,8})/g)
|
|
827
967
|
].map((m) => m[1]);
|
|
@@ -856,12 +996,14 @@ function checkRestraint(input) {
|
|
|
856
996
|
}
|
|
857
997
|
const easeIn = [
|
|
858
998
|
...css.matchAll(/(?:transition|animation)[^;}]*\b(ease-in)\b(?!-out)/gi),
|
|
859
|
-
...css.matchAll(
|
|
999
|
+
...css.matchAll(
|
|
1000
|
+
/cubic-bezier\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/gi
|
|
1001
|
+
)
|
|
860
1002
|
].map((m) => {
|
|
861
1003
|
if (m[1] === "ease-in") return "ease-in";
|
|
862
|
-
const
|
|
863
|
-
|
|
864
|
-
return
|
|
1004
|
+
const p = [m[1], m[2], m[3], m[4]].map(Number);
|
|
1005
|
+
if (!p.every((n) => Number.isFinite(n))) return null;
|
|
1006
|
+
return acceleratesIntoTheEnd(p[0], p[1], p[2], p[3]) ? m[0] : null;
|
|
865
1007
|
}).filter((v) => v !== null);
|
|
866
1008
|
if (easeIn.length > 0) {
|
|
867
1009
|
push(
|
|
@@ -871,7 +1013,9 @@ function checkRestraint(input) {
|
|
|
871
1013
|
[...new Set(easeIn)]
|
|
872
1014
|
);
|
|
873
1015
|
}
|
|
874
|
-
const overBudget = durationsMs(css).filter(
|
|
1016
|
+
const overBudget = durationsMs(css).filter(
|
|
1017
|
+
(d) => d.ms > budget.maxUiDurationMs
|
|
1018
|
+
);
|
|
875
1019
|
if (overBudget.length > 0) {
|
|
876
1020
|
push(
|
|
877
1021
|
"duration",
|
|
@@ -888,8 +1032,11 @@ function checkRestraint(input) {
|
|
|
888
1032
|
["scale(0)"]
|
|
889
1033
|
);
|
|
890
1034
|
}
|
|
891
|
-
const
|
|
892
|
-
|
|
1035
|
+
const reducedMotionBlocks = atRuleBodies(
|
|
1036
|
+
css,
|
|
1037
|
+
/@media[^{]*prefers-reduced-motion:\s*reduce[^{]*\{/gi
|
|
1038
|
+
);
|
|
1039
|
+
if (reducedMotionBlocks.length === 0) {
|
|
893
1040
|
if (/@keyframes|animation\s*:|transition\s*:/.test(css)) {
|
|
894
1041
|
push(
|
|
895
1042
|
"reduced-motion-missing",
|
|
@@ -899,7 +1046,7 @@ function checkRestraint(input) {
|
|
|
899
1046
|
);
|
|
900
1047
|
}
|
|
901
1048
|
} else {
|
|
902
|
-
const block =
|
|
1049
|
+
const block = reducedMotionBlocks.join("\n");
|
|
903
1050
|
if (/animation\s*:\s*none/i.test(block)) {
|
|
904
1051
|
push(
|
|
905
1052
|
"reduced-motion-animation-none",
|
|
@@ -935,6 +1082,17 @@ function checkRestraint(input) {
|
|
|
935
1082
|
}
|
|
936
1083
|
};
|
|
937
1084
|
}
|
|
1085
|
+
function acceleratesIntoTheEnd(x1, y1, x2, y2) {
|
|
1086
|
+
const EPSILON = 1e-6;
|
|
1087
|
+
let dx = 1 - x2;
|
|
1088
|
+
let dy = 1 - y2;
|
|
1089
|
+
if (Math.abs(dx) < EPSILON && Math.abs(dy) < EPSILON) {
|
|
1090
|
+
dx = 1 - x1;
|
|
1091
|
+
dy = 1 - y1;
|
|
1092
|
+
}
|
|
1093
|
+
if (Math.abs(dx) < EPSILON) return Math.abs(dy) > EPSILON;
|
|
1094
|
+
return dy / dx > 1 + EPSILON;
|
|
1095
|
+
}
|
|
938
1096
|
function hueSpread(hues) {
|
|
939
1097
|
const sorted = [...hues].sort((a, b) => a - b);
|
|
940
1098
|
let widestGap = 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@domandigital/craft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|