@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 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**. A compiled stylesheet also
121
- carries the framework's reset and theme, and `@layer theme` / `@layer base` are
122
- excluded by default for that reason — counting them reports a surface as over
123
- budget for values its author never wrote, and real findings then get triaged
124
- away with the noise.
125
-
126
- *Source: measured against the estate; the budget is the observed ceiling of the surfaces that read as designed. The layer exclusion is measured too — Tailwind v4's reset and theme contribute three font sizes and two timing functions to every sheet built with it.*
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(["inherit", "initial", "unset", "revert", "revert-layer"]);
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(prelude.source, prelude.flags.includes("g") ? prelude.flags : `${prelude.flags}g`);
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 distinctValues(css, property) {
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 value = match[1].trim().replace(/\s+/g, " ").toLowerCase();
879
- if (value.startsWith("var(") || CSS_WIDE_KEYWORDS.has(value)) continue;
880
- seen.add(value);
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
- stripComments(input.css),
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 fontSizes = distinctValues(css, "font-size");
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((w) => w !== "normal" && w !== "bold");
913
- const namedWeights = distinctValues(css, "font-weight").filter(
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((r) => r !== "0" && r !== "0px");
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("radii", "error", `${radii.length} corner radii, budget ${budget.maxRadii}.`, radii);
1019
+ push(
1020
+ "radii",
1021
+ "error",
1022
+ `${radii.length} corner radii, budget ${budget.maxRadii}.`,
1023
+ radii
1024
+ );
939
1025
  }
940
- const shadows = distinctValues(css, "box-shadow").filter((s) => s !== "none");
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((d) => d.ms > budget.maxUiDurationMs);
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(css, /@media[^{]*prefers-reduced-motion:\s*reduce[^{]*\{/gi);
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(["inherit", "initial", "unset", "revert", "revert-layer"]);
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(prelude.source, prelude.flags.includes("g") ? prelude.flags : `${prelude.flags}g`);
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 distinctValues(css, property) {
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 value = match[1].trim().replace(/\s+/g, " ").toLowerCase();
807
- if (value.startsWith("var(") || CSS_WIDE_KEYWORDS.has(value)) continue;
808
- seen.add(value);
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
- stripComments(input.css),
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 fontSizes = distinctValues(css, "font-size");
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((w) => w !== "normal" && w !== "bold");
841
- const namedWeights = distinctValues(css, "font-weight").filter(
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((r) => r !== "0" && r !== "0px");
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("radii", "error", `${radii.length} corner radii, budget ${budget.maxRadii}.`, radii);
947
+ push(
948
+ "radii",
949
+ "error",
950
+ `${radii.length} corner radii, budget ${budget.maxRadii}.`,
951
+ radii
952
+ );
867
953
  }
868
- const shadows = distinctValues(css, "box-shadow").filter((s) => s !== "none");
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((d) => d.ms > budget.maxUiDurationMs);
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(css, /@media[^{]*prefers-reduced-motion:\s*reduce[^{]*\{/gi);
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.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",