@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 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`.** That drops the `forwards` fill along with the
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
- *Source: measured against the estate; the budget is the observed ceiling of the surfaces that read as designed.*
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 distinctValues(css, property) {
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 value = match[1].trim().replace(/\s+/g, " ").toLowerCase();
830
- if (value.startsWith("var(") || value === "inherit" || value === "initial") continue;
831
- seen.add(value);
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 css = stripComments(input.css);
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((w) => w !== "normal" && w !== "bold");
861
- const namedWeights = distinctValues(css, "font-weight").filter(
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((r) => r !== "0" && r !== "0px");
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("radii", "error", `${radii.length} corner radii, budget ${budget.maxRadii}.`, radii);
1008
+ push(
1009
+ "radii",
1010
+ "error",
1011
+ `${radii.length} corner radii, budget ${budget.maxRadii}.`,
1012
+ radii
1013
+ );
887
1014
  }
888
- const shadows = distinctValues(css, "box-shadow").filter((s) => s !== "none");
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(/cubic-bezier\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,/gi)
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 x1 = Number(m[1]);
935
- const y1 = Number(m[2]);
936
- return Number.isFinite(x1) && Number.isFinite(y1) && y1 < x1 ? m[0] : null;
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((d) => d.ms > budget.maxUiDurationMs);
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 reducedMotionAt = css.search(/@media[^{]*prefers-reduced-motion:\s*reduce/i);
964
- if (reducedMotionAt === -1) {
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 = css.slice(reducedMotionAt);
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 distinctValues(css, property) {
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 value = match[1].trim().replace(/\s+/g, " ").toLowerCase();
758
- if (value.startsWith("var(") || value === "inherit" || value === "initial") continue;
759
- seen.add(value);
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 css = stripComments(input.css);
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((w) => w !== "normal" && w !== "bold");
789
- const namedWeights = distinctValues(css, "font-weight").filter(
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((r) => r !== "0" && r !== "0px");
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("radii", "error", `${radii.length} corner radii, budget ${budget.maxRadii}.`, radii);
936
+ push(
937
+ "radii",
938
+ "error",
939
+ `${radii.length} corner radii, budget ${budget.maxRadii}.`,
940
+ radii
941
+ );
815
942
  }
816
- const shadows = distinctValues(css, "box-shadow").filter((s) => s !== "none");
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(/cubic-bezier\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,/gi)
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 x1 = Number(m[1]);
863
- const y1 = Number(m[2]);
864
- return Number.isFinite(x1) && Number.isFinite(y1) && y1 < x1 ? m[0] : null;
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((d) => d.ms > budget.maxUiDurationMs);
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 reducedMotionAt = css.search(/@media[^{]*prefers-reduced-motion:\s*reduce/i);
892
- if (reducedMotionAt === -1) {
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 = css.slice(reducedMotionAt);
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.2.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",