@domandigital/craft 0.1.0 → 0.3.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 +61 -0
- package/STANDARD.md +12 -3
- package/css/craft.css +31 -6
- package/dist/index.cjs +74 -9
- package/dist/index.d.cts +18 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +74 -9
- package/package.json +7 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# @domandigital/craft
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2075e76: `checkRestraint` measures the surface, not the framework underneath it.
|
|
8
|
+
|
|
9
|
+
Two defects, both found by running the checker against a real compiled
|
|
10
|
+
stylesheet — `apps/portal` in the Doman Digital monorepo — rather than against
|
|
11
|
+
its own fixtures.
|
|
12
|
+
|
|
13
|
+
**It rejected `EASE.inOut`, one of its own canonical curves.** The ease-in test
|
|
14
|
+
compared only the first control point (`y1 < x1`), which is true of every
|
|
15
|
+
ease-in-_out_ as well. So the standard failed its own checker on any surface
|
|
16
|
+
that adopted the token the standard recommends. Section 7's objection to ease-in
|
|
17
|
+
is about the end of the movement — a curve that accelerates into its final
|
|
18
|
+
position reads as the interface pulling away — so the test is now the end
|
|
19
|
+
tangent: rejected when the curve covers more ground per unit time arriving than
|
|
20
|
+
it averaged. `EASE.out`, `EASE.inOut`, `EASE.drawer`, `EASE.reveal`, Tailwind's
|
|
21
|
+
default and `linear` all pass; the `ease-in` keyword's curve, the portal's old
|
|
22
|
+
`--ease-exit`, and a back-loaded custom curve are all still rejected.
|
|
23
|
+
|
|
24
|
+
**It counted the framework's reset and theme as house vocabulary.** Tailwind v4
|
|
25
|
+
alone contributes three font sizes nobody chose (`small` at 80%, `sub`/`sup` at
|
|
26
|
+
75%, `code` at 1em) and two timing functions, which was enough to report a
|
|
27
|
+
surface as over its font-size budget for values its author never wrote.
|
|
28
|
+
`@layer theme` and `@layer base` are now excluded by default via a new
|
|
29
|
+
`ignoreAtLayers` option; pass `[]` to count everything, as before.
|
|
30
|
+
|
|
31
|
+
**Its reduced-motion check read the rest of the file, not the block.** It sliced
|
|
32
|
+
from the first `@media (prefers-reduced-motion: reduce)` to the end of the sheet,
|
|
33
|
+
so every rule after that point counted as being inside it. On a real sheet that
|
|
34
|
+
meant `[data-craft-lcp] { animation: none }` — this package's own section 7 rule,
|
|
35
|
+
correctly outside any media query — was reported as a section 8 violation. The
|
|
36
|
+
scan is now brace-matched to each block's own body, and reads every such block
|
|
37
|
+
rather than only the first.
|
|
38
|
+
|
|
39
|
+
**CSS-wide keywords are no longer counted as vocabulary.** `inherit` and
|
|
40
|
+
`initial` were already excluded; `unset`, `revert` and `revert-layer` were not,
|
|
41
|
+
and `revert` appears in compiled output. A declaration that defers to the cascade
|
|
42
|
+
names no value, so it is not a step of anything.
|
|
43
|
+
|
|
44
|
+
## 0.2.0
|
|
45
|
+
|
|
46
|
+
### Minor Changes
|
|
47
|
+
|
|
48
|
+
- 6462b3a: Use the `text-wrap-style` longhand, and stop applying orphan control to every
|
|
49
|
+
`li`.
|
|
50
|
+
|
|
51
|
+
`text-wrap` is shorthand for `text-wrap-mode` + `text-wrap-style`, so
|
|
52
|
+
`text-wrap: pretty` silently resets the mode to `wrap`. `white-space: nowrap` is
|
|
53
|
+
itself shorthand setting `text-wrap-mode: nowrap`, so the shorthand defeated it
|
|
54
|
+
along with any `text-overflow: ellipsis` truncation that relied on it.
|
|
55
|
+
|
|
56
|
+
Caught by a visual regression suite: `li { text-wrap: pretty }` expanded a
|
|
57
|
+
breadcrumb that truncates to one line into three lines and pushed the page 40px
|
|
58
|
+
taller. The longhand sets only the style, so an element that asked not to wrap
|
|
59
|
+
still does not.
|
|
60
|
+
|
|
61
|
+
`li` is now scoped to `.craft-prose li`. Breadcrumbs, navs, tab strips and menus
|
|
62
|
+
are all `li` and none of them are body copy.
|
|
63
|
+
|
|
3
64
|
## 0.1.0
|
|
4
65
|
|
|
5
66
|
### 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,13 @@ 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**. 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.*
|
|
118
127
|
|
|
119
128
|
## 10. What is deliberately not a rule
|
|
120
129
|
|
package/css/craft.css
CHANGED
|
@@ -21,6 +21,25 @@ html {
|
|
|
21
21
|
text-rendering: optimizeLegibility;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/*
|
|
25
|
+
* THE LONGHAND, NOT `text-wrap`. This matters.
|
|
26
|
+
*
|
|
27
|
+
* `text-wrap` is a shorthand for `text-wrap-mode` + `text-wrap-style`, so
|
|
28
|
+
* `text-wrap: pretty` silently resets the MODE to `wrap`. `white-space: nowrap`
|
|
29
|
+
* is itself shorthand for `white-space-collapse: collapse` + `text-wrap-mode:
|
|
30
|
+
* nowrap`, so any element the shorthand touches loses its nowrap — and with it
|
|
31
|
+
* any `text-overflow: ellipsis` truncation that depended on it.
|
|
32
|
+
*
|
|
33
|
+
* That is not hypothetical: `li { text-wrap: pretty }` broke a breadcrumb that
|
|
34
|
+
* truncates to one line, expanding it to three and pushing the page 40px taller.
|
|
35
|
+
* `text-wrap-style` sets only the style and leaves the mode alone, so an element
|
|
36
|
+
* that asked not to wrap still does not.
|
|
37
|
+
*
|
|
38
|
+
* Browsers without `text-wrap-style` simply get no orphan control, which is the
|
|
39
|
+
* correct degradation. A `text-wrap` shorthand fallback would reintroduce the
|
|
40
|
+
* bug, so there deliberately isn't one.
|
|
41
|
+
*/
|
|
42
|
+
|
|
24
43
|
/* A heading that wraps one word onto its own line looks like a mistake.
|
|
25
44
|
`balance` evens the lines; it is capped at ~6 lines by the spec, which is why
|
|
26
45
|
it is scoped to headings and not applied to body copy. */
|
|
@@ -31,16 +50,22 @@ h4,
|
|
|
31
50
|
h5,
|
|
32
51
|
h6,
|
|
33
52
|
.craft-balance {
|
|
34
|
-
text-wrap: balance;
|
|
53
|
+
text-wrap-style: balance;
|
|
35
54
|
}
|
|
36
55
|
|
|
37
|
-
/*
|
|
38
|
-
|
|
56
|
+
/*
|
|
57
|
+
* `pretty` is the body-copy counterpart: it only prevents orphans, so it stays
|
|
58
|
+
* cheap on long text where `balance` would not.
|
|
59
|
+
*
|
|
60
|
+
* `li` is deliberately scoped to prose. Breadcrumbs, navs, tab strips and menus
|
|
61
|
+
* are all `li` and none of them are body copy; orphan control there is at best
|
|
62
|
+
* meaningless and at worst fights a deliberate truncation.
|
|
63
|
+
*/
|
|
39
64
|
p,
|
|
40
|
-
li,
|
|
41
65
|
figcaption,
|
|
42
|
-
blockquote
|
|
43
|
-
|
|
66
|
+
blockquote,
|
|
67
|
+
.craft-prose li {
|
|
68
|
+
text-wrap-style: pretty;
|
|
44
69
|
}
|
|
45
70
|
|
|
46
71
|
.craft-prose {
|
package/dist/index.cjs
CHANGED
|
@@ -819,15 +819,64 @@ 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
|
}
|
|
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(["inherit", "initial", "unset", "revert", "revert-layer"]);
|
|
856
|
+
function atRuleBodies(css, prelude) {
|
|
857
|
+
const out = [];
|
|
858
|
+
const pattern = new RegExp(prelude.source, prelude.flags.includes("g") ? prelude.flags : `${prelude.flags}g`);
|
|
859
|
+
let match;
|
|
860
|
+
while ((match = pattern.exec(css)) !== null) {
|
|
861
|
+
const start = match.index + match[0].length;
|
|
862
|
+
let depth = 1;
|
|
863
|
+
let i = start;
|
|
864
|
+
while (i < css.length && depth > 0) {
|
|
865
|
+
if (css[i] === "{") depth += 1;
|
|
866
|
+
else if (css[i] === "}") depth -= 1;
|
|
867
|
+
i += 1;
|
|
868
|
+
}
|
|
869
|
+
out.push(css.slice(start, i - 1));
|
|
870
|
+
pattern.lastIndex = i;
|
|
871
|
+
}
|
|
872
|
+
return out;
|
|
873
|
+
}
|
|
825
874
|
function distinctValues(css, property) {
|
|
826
875
|
const pattern = new RegExp(`(?:^|[;{\\s])${property}\\s*:\\s*([^;}]+)`, "gi");
|
|
827
876
|
const seen = /* @__PURE__ */ new Set();
|
|
828
877
|
for (const match of css.matchAll(pattern)) {
|
|
829
878
|
const value = match[1].trim().replace(/\s+/g, " ").toLowerCase();
|
|
830
|
-
if (value.startsWith("var(") || value
|
|
879
|
+
if (value.startsWith("var(") || CSS_WIDE_KEYWORDS.has(value)) continue;
|
|
831
880
|
seen.add(value);
|
|
832
881
|
}
|
|
833
882
|
return [...seen];
|
|
@@ -843,7 +892,10 @@ function durationsMs(css) {
|
|
|
843
892
|
}
|
|
844
893
|
function checkRestraint(input) {
|
|
845
894
|
const budget = { ...HOUSE_BUDGET, ...input.budget };
|
|
846
|
-
const css =
|
|
895
|
+
const css = stripLayers(
|
|
896
|
+
stripComments(input.css),
|
|
897
|
+
input.ignoreAtLayers ?? DEFAULT_IGNORED_LAYERS
|
|
898
|
+
);
|
|
847
899
|
const violations = [];
|
|
848
900
|
const push = (rule, severity, message, found) => {
|
|
849
901
|
violations.push({ rule, severity, message, found });
|
|
@@ -928,12 +980,14 @@ function checkRestraint(input) {
|
|
|
928
980
|
}
|
|
929
981
|
const easeIn = [
|
|
930
982
|
...css.matchAll(/(?:transition|animation)[^;}]*\b(ease-in)\b(?!-out)/gi),
|
|
931
|
-
...css.matchAll(
|
|
983
|
+
...css.matchAll(
|
|
984
|
+
/cubic-bezier\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/gi
|
|
985
|
+
)
|
|
932
986
|
].map((m) => {
|
|
933
987
|
if (m[1] === "ease-in") return "ease-in";
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
return
|
|
988
|
+
const p = [m[1], m[2], m[3], m[4]].map(Number);
|
|
989
|
+
if (!p.every((n) => Number.isFinite(n))) return null;
|
|
990
|
+
return acceleratesIntoTheEnd(p[0], p[1], p[2], p[3]) ? m[0] : null;
|
|
937
991
|
}).filter((v) => v !== null);
|
|
938
992
|
if (easeIn.length > 0) {
|
|
939
993
|
push(
|
|
@@ -960,8 +1014,8 @@ function checkRestraint(input) {
|
|
|
960
1014
|
["scale(0)"]
|
|
961
1015
|
);
|
|
962
1016
|
}
|
|
963
|
-
const
|
|
964
|
-
if (
|
|
1017
|
+
const reducedMotionBlocks = atRuleBodies(css, /@media[^{]*prefers-reduced-motion:\s*reduce[^{]*\{/gi);
|
|
1018
|
+
if (reducedMotionBlocks.length === 0) {
|
|
965
1019
|
if (/@keyframes|animation\s*:|transition\s*:/.test(css)) {
|
|
966
1020
|
push(
|
|
967
1021
|
"reduced-motion-missing",
|
|
@@ -971,7 +1025,7 @@ function checkRestraint(input) {
|
|
|
971
1025
|
);
|
|
972
1026
|
}
|
|
973
1027
|
} else {
|
|
974
|
-
const block =
|
|
1028
|
+
const block = reducedMotionBlocks.join("\n");
|
|
975
1029
|
if (/animation\s*:\s*none/i.test(block)) {
|
|
976
1030
|
push(
|
|
977
1031
|
"reduced-motion-animation-none",
|
|
@@ -1007,6 +1061,17 @@ function checkRestraint(input) {
|
|
|
1007
1061
|
}
|
|
1008
1062
|
};
|
|
1009
1063
|
}
|
|
1064
|
+
function acceleratesIntoTheEnd(x1, y1, x2, y2) {
|
|
1065
|
+
const EPSILON = 1e-6;
|
|
1066
|
+
let dx = 1 - x2;
|
|
1067
|
+
let dy = 1 - y2;
|
|
1068
|
+
if (Math.abs(dx) < EPSILON && Math.abs(dy) < EPSILON) {
|
|
1069
|
+
dx = 1 - x1;
|
|
1070
|
+
dy = 1 - y1;
|
|
1071
|
+
}
|
|
1072
|
+
if (Math.abs(dx) < EPSILON) return Math.abs(dy) > EPSILON;
|
|
1073
|
+
return dy / dx > 1 + EPSILON;
|
|
1074
|
+
}
|
|
1010
1075
|
function hueSpread(hues) {
|
|
1011
1076
|
const sorted = [...hues].sort((a, b) => a - b);
|
|
1012
1077
|
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,15 +747,64 @@ 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
|
}
|
|
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(["inherit", "initial", "unset", "revert", "revert-layer"]);
|
|
784
|
+
function atRuleBodies(css, prelude) {
|
|
785
|
+
const out = [];
|
|
786
|
+
const pattern = new RegExp(prelude.source, prelude.flags.includes("g") ? prelude.flags : `${prelude.flags}g`);
|
|
787
|
+
let match;
|
|
788
|
+
while ((match = pattern.exec(css)) !== null) {
|
|
789
|
+
const start = match.index + match[0].length;
|
|
790
|
+
let depth = 1;
|
|
791
|
+
let i = start;
|
|
792
|
+
while (i < css.length && depth > 0) {
|
|
793
|
+
if (css[i] === "{") depth += 1;
|
|
794
|
+
else if (css[i] === "}") depth -= 1;
|
|
795
|
+
i += 1;
|
|
796
|
+
}
|
|
797
|
+
out.push(css.slice(start, i - 1));
|
|
798
|
+
pattern.lastIndex = i;
|
|
799
|
+
}
|
|
800
|
+
return out;
|
|
801
|
+
}
|
|
753
802
|
function distinctValues(css, property) {
|
|
754
803
|
const pattern = new RegExp(`(?:^|[;{\\s])${property}\\s*:\\s*([^;}]+)`, "gi");
|
|
755
804
|
const seen = /* @__PURE__ */ new Set();
|
|
756
805
|
for (const match of css.matchAll(pattern)) {
|
|
757
806
|
const value = match[1].trim().replace(/\s+/g, " ").toLowerCase();
|
|
758
|
-
if (value.startsWith("var(") || value
|
|
807
|
+
if (value.startsWith("var(") || CSS_WIDE_KEYWORDS.has(value)) continue;
|
|
759
808
|
seen.add(value);
|
|
760
809
|
}
|
|
761
810
|
return [...seen];
|
|
@@ -771,7 +820,10 @@ function durationsMs(css) {
|
|
|
771
820
|
}
|
|
772
821
|
function checkRestraint(input) {
|
|
773
822
|
const budget = { ...HOUSE_BUDGET, ...input.budget };
|
|
774
|
-
const css =
|
|
823
|
+
const css = stripLayers(
|
|
824
|
+
stripComments(input.css),
|
|
825
|
+
input.ignoreAtLayers ?? DEFAULT_IGNORED_LAYERS
|
|
826
|
+
);
|
|
775
827
|
const violations = [];
|
|
776
828
|
const push = (rule, severity, message, found) => {
|
|
777
829
|
violations.push({ rule, severity, message, found });
|
|
@@ -856,12 +908,14 @@ function checkRestraint(input) {
|
|
|
856
908
|
}
|
|
857
909
|
const easeIn = [
|
|
858
910
|
...css.matchAll(/(?:transition|animation)[^;}]*\b(ease-in)\b(?!-out)/gi),
|
|
859
|
-
...css.matchAll(
|
|
911
|
+
...css.matchAll(
|
|
912
|
+
/cubic-bezier\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/gi
|
|
913
|
+
)
|
|
860
914
|
].map((m) => {
|
|
861
915
|
if (m[1] === "ease-in") return "ease-in";
|
|
862
|
-
const
|
|
863
|
-
|
|
864
|
-
return
|
|
916
|
+
const p = [m[1], m[2], m[3], m[4]].map(Number);
|
|
917
|
+
if (!p.every((n) => Number.isFinite(n))) return null;
|
|
918
|
+
return acceleratesIntoTheEnd(p[0], p[1], p[2], p[3]) ? m[0] : null;
|
|
865
919
|
}).filter((v) => v !== null);
|
|
866
920
|
if (easeIn.length > 0) {
|
|
867
921
|
push(
|
|
@@ -888,8 +942,8 @@ function checkRestraint(input) {
|
|
|
888
942
|
["scale(0)"]
|
|
889
943
|
);
|
|
890
944
|
}
|
|
891
|
-
const
|
|
892
|
-
if (
|
|
945
|
+
const reducedMotionBlocks = atRuleBodies(css, /@media[^{]*prefers-reduced-motion:\s*reduce[^{]*\{/gi);
|
|
946
|
+
if (reducedMotionBlocks.length === 0) {
|
|
893
947
|
if (/@keyframes|animation\s*:|transition\s*:/.test(css)) {
|
|
894
948
|
push(
|
|
895
949
|
"reduced-motion-missing",
|
|
@@ -899,7 +953,7 @@ function checkRestraint(input) {
|
|
|
899
953
|
);
|
|
900
954
|
}
|
|
901
955
|
} else {
|
|
902
|
-
const block =
|
|
956
|
+
const block = reducedMotionBlocks.join("\n");
|
|
903
957
|
if (/animation\s*:\s*none/i.test(block)) {
|
|
904
958
|
push(
|
|
905
959
|
"reduced-motion-animation-none",
|
|
@@ -935,6 +989,17 @@ function checkRestraint(input) {
|
|
|
935
989
|
}
|
|
936
990
|
};
|
|
937
991
|
}
|
|
992
|
+
function acceleratesIntoTheEnd(x1, y1, x2, y2) {
|
|
993
|
+
const EPSILON = 1e-6;
|
|
994
|
+
let dx = 1 - x2;
|
|
995
|
+
let dy = 1 - y2;
|
|
996
|
+
if (Math.abs(dx) < EPSILON && Math.abs(dy) < EPSILON) {
|
|
997
|
+
dx = 1 - x1;
|
|
998
|
+
dy = 1 - y1;
|
|
999
|
+
}
|
|
1000
|
+
if (Math.abs(dx) < EPSILON) return Math.abs(dy) > EPSILON;
|
|
1001
|
+
return dy / dx > 1 + EPSILON;
|
|
1002
|
+
}
|
|
938
1003
|
function hueSpread(hues) {
|
|
939
1004
|
const sorted = [...hues].sort((a, b) => a - b);
|
|
940
1005
|
let widestGap = 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@domandigital/craft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -30,12 +30,6 @@
|
|
|
30
30
|
"css",
|
|
31
31
|
"dist"
|
|
32
32
|
],
|
|
33
|
-
"scripts": {
|
|
34
|
-
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
35
|
-
"typecheck": "tsc --noEmit",
|
|
36
|
-
"test": "vitest run",
|
|
37
|
-
"prepublishOnly": "pnpm run build"
|
|
38
|
-
},
|
|
39
33
|
"devDependencies": {
|
|
40
34
|
"tsup": "^8.3.5",
|
|
41
35
|
"typescript": "^5.6.3",
|
|
@@ -44,7 +38,6 @@
|
|
|
44
38
|
"engines": {
|
|
45
39
|
"node": ">=20"
|
|
46
40
|
},
|
|
47
|
-
"packageManager": "pnpm@9.15.9",
|
|
48
41
|
"repository": {
|
|
49
42
|
"type": "git",
|
|
50
43
|
"url": "git+https://github.com/Doman-Digital/dd-packages.git",
|
|
@@ -57,5 +50,10 @@
|
|
|
57
50
|
"publishConfig": {
|
|
58
51
|
"access": "public",
|
|
59
52
|
"provenance": true
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "vitest run"
|
|
60
58
|
}
|
|
61
|
-
}
|
|
59
|
+
}
|