@crisp-ui-kit/crisp 0.32.1 → 0.33.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.
@@ -1,60 +1,119 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.badgeRecipe = void 0;
3
+ exports.spec = exports.badgeRecipe = void 0;
4
4
  const core_1 = require("../../core");
5
5
  const tokens_1 = require("../../tokens");
6
- const config = (0, core_1.defineRecipe)({
6
+ const ring = (v, pct) => `inset 0 0 0 1px color-mix(in srgb, ${v} ${pct}%, transparent)`;
7
+ const soft = (v, pct) => `color-mix(in srgb, ${v} ${pct}%, transparent)`;
8
+ const spec = (0, core_1.defineSpec)({
7
9
  name: "badge",
8
- base: `display: inline-flex; align-items: center; justify-content: center; gap: ${tokens_1.contract.space["1"]}; font-family: ${tokens_1.contract.font.sans}; font-weight: ${tokens_1.contract.weight.medium}; letter-spacing: -0.1px; white-space: nowrap;
9
- & .crisp-badge-dot { width: 6px; height: 6px; flex-shrink: 0; border-radius: ${tokens_1.contract.radius.full}; background: currentColor; }`,
10
+ base: {
11
+ display: "inline-flex",
12
+ alignItems: "center",
13
+ justifyContent: "center",
14
+ gap: tokens_1.contract.space["1"],
15
+ fontFamily: tokens_1.contract.font.sans,
16
+ fontWeight: tokens_1.contract.weight.medium,
17
+ letterSpacing: "-0.1px",
18
+ whiteSpace: "nowrap",
19
+ },
20
+ parts: [
21
+ {
22
+ slot: "dot",
23
+ selector: "& .crisp-badge-dot",
24
+ decls: {
25
+ width: "6px",
26
+ height: "6px",
27
+ flexShrink: 0,
28
+ borderRadius: tokens_1.contract.radius.full,
29
+ background: "currentColor",
30
+ },
31
+ },
32
+ ],
10
33
  variants: {
11
34
  tone: {
12
- neutral: `background: ${tokens_1.contract.bgRaised}; color: ${tokens_1.contract.fgMuted}; box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderDefault};`,
13
- brand: `background: color-mix(in srgb, ${tokens_1.contract.bgBrandSolid} 14%, transparent); color: ${tokens_1.contract.bgBrandSolid}; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${tokens_1.contract.bgBrandSolid} 30%, transparent);`,
14
- success: `background: color-mix(in srgb, ${tokens_1.contract.bgSuccess} 14%, transparent); color: ${tokens_1.contract.bgSuccess}; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${tokens_1.contract.bgSuccess} 30%, transparent);`,
15
- warning: `background: color-mix(in srgb, ${tokens_1.contract.bgWarning} 16%, transparent); color: ${tokens_1.contract.bgWarning}; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${tokens_1.contract.bgWarning} 32%, transparent);`,
16
- danger: `background: color-mix(in srgb, ${tokens_1.contract.bgDanger} 14%, transparent); color: ${tokens_1.contract.bgDanger}; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${tokens_1.contract.bgDanger} 30%, transparent);`,
35
+ neutral: {
36
+ background: tokens_1.contract.bgRaised,
37
+ color: tokens_1.contract.fgMuted,
38
+ boxShadow: `inset 0 0 0 1px ${tokens_1.contract.borderDefault}`,
39
+ },
40
+ brand: {
41
+ background: soft(tokens_1.contract.bgBrandSolid, 14),
42
+ color: tokens_1.contract.bgBrandSolid,
43
+ boxShadow: ring(tokens_1.contract.bgBrandSolid, 30),
44
+ },
45
+ success: {
46
+ background: soft(tokens_1.contract.bgSuccess, 14),
47
+ color: tokens_1.contract.bgSuccess,
48
+ boxShadow: ring(tokens_1.contract.bgSuccess, 30),
49
+ },
50
+ warning: {
51
+ background: soft(tokens_1.contract.bgWarning, 16),
52
+ color: tokens_1.contract.bgWarning,
53
+ boxShadow: ring(tokens_1.contract.bgWarning, 32),
54
+ },
55
+ danger: {
56
+ background: soft(tokens_1.contract.bgDanger, 14),
57
+ color: tokens_1.contract.bgDanger,
58
+ boxShadow: ring(tokens_1.contract.bgDanger, 30),
59
+ },
17
60
  },
18
61
  size: {
19
- small: `height: 16px; padding-inline: ${tokens_1.contract.space["1.5"]}; font-size: ${tokens_1.contract.text.xs}; border-radius: ${tokens_1.contract.radius.sm};`,
20
- medium: `height: 20px; padding-inline: ${tokens_1.contract.space["2"]}; font-size: ${tokens_1.contract.text.sm}; border-radius: ${tokens_1.contract.radius.sm};`,
21
- large: `height: ${tokens_1.contract.size.chip}; padding-inline: 9px; font-size: ${tokens_1.contract.text.lg}; border-radius: ${tokens_1.contract.radius.chip};`,
22
- },
23
- variant: {
24
- soft: ``,
25
- solid: ``,
62
+ small: {
63
+ height: "16px",
64
+ paddingInline: tokens_1.contract.space["1.5"],
65
+ fontSize: tokens_1.contract.text.xs,
66
+ borderRadius: tokens_1.contract.radius.sm,
67
+ },
68
+ medium: {
69
+ height: "20px",
70
+ paddingInline: tokens_1.contract.space["2"],
71
+ fontSize: tokens_1.contract.text.sm,
72
+ borderRadius: tokens_1.contract.radius.sm,
73
+ },
74
+ large: {
75
+ height: tokens_1.contract.size.chip,
76
+ paddingInline: "9px",
77
+ fontSize: tokens_1.contract.text.lg,
78
+ borderRadius: tokens_1.contract.radius.chip,
79
+ },
26
80
  },
81
+ variant: { soft: {}, solid: {} },
27
82
  },
28
83
  defaultVariants: { tone: "neutral", size: "medium", variant: "soft" },
29
84
  compoundVariants: [
30
85
  {
31
86
  variant: "solid",
32
87
  tone: "brand",
33
- css: `background: ${tokens_1.contract.bgBrandSolid}; color: ${tokens_1.contract.fgOnBrand}; box-shadow: none;`,
88
+ decls: {
89
+ background: tokens_1.contract.bgBrandSolid,
90
+ color: tokens_1.contract.fgOnBrand,
91
+ boxShadow: "none",
92
+ },
34
93
  },
35
94
  {
36
95
  variant: "solid",
37
96
  tone: "success",
38
- css: `background: ${tokens_1.contract.bgSuccess}; color: ${tokens_1.contract.fgOnTone}; box-shadow: none;`,
97
+ decls: { background: tokens_1.contract.bgSuccess, color: tokens_1.contract.fgOnTone, boxShadow: "none" },
39
98
  },
40
99
  {
41
100
  variant: "solid",
42
101
  tone: "warning",
43
- css: `background: ${tokens_1.contract.bgWarning}; color: ${tokens_1.contract.fgOnTone}; box-shadow: none;`,
102
+ decls: { background: tokens_1.contract.bgWarning, color: tokens_1.contract.fgOnTone, boxShadow: "none" },
44
103
  },
45
104
  {
46
105
  variant: "solid",
47
106
  tone: "danger",
48
- css: `background: ${tokens_1.contract.bgDanger}; color: ${tokens_1.contract.fgOnTone}; box-shadow: none;`,
107
+ decls: { background: tokens_1.contract.bgDanger, color: tokens_1.contract.fgOnTone, boxShadow: "none" },
49
108
  },
50
109
  {
51
110
  variant: "solid",
52
111
  tone: "neutral",
53
- css: `background: ${tokens_1.contract.fgMuted}; color: ${tokens_1.contract.bgSurface}; box-shadow: none;`,
112
+ decls: { background: tokens_1.contract.fgMuted, color: tokens_1.contract.bgSurface, boxShadow: "none" },
54
113
  },
55
114
  ],
56
115
  });
57
- exports.badgeRecipe = Object.assign((0, core_1.resolveRecipe)(config), {
58
- config,
59
- });
116
+ exports.spec = spec;
117
+ const config = (0, core_1.specToRecipe)(spec);
118
+ exports.badgeRecipe = Object.assign((0, core_1.resolveRecipe)(config), { config });
60
119
  exports.default = config;
@@ -1,17 +1,29 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.dividerRecipe = void 0;
3
+ exports.spec = exports.dividerRecipe = void 0;
4
4
  const core_1 = require("../../core");
5
5
  const tokens_1 = require("../../tokens");
6
6
  // A hairline separator — horizontal (default) or vertical.
7
- const config = (0, core_1.defineRecipe)({
7
+ const spec = (0, core_1.defineSpec)({
8
8
  name: "divider",
9
- base: `flex-shrink: 0; border: none; margin: 0; background: ${tokens_1.contract.borderSubtle};
10
- &[data-orientation="horizontal"] { width: 100%; height: 1px; }
11
- &[data-orientation="vertical"] { width: 1px; align-self: stretch; min-height: 1em; }`,
12
- variants: {},
13
- defaultVariants: {},
14
- compoundVariants: [],
9
+ base: {
10
+ flexShrink: 0,
11
+ border: "none",
12
+ margin: 0,
13
+ background: tokens_1.contract.borderSubtle,
14
+ },
15
+ parts: [
16
+ {
17
+ selector: '&[data-orientation="horizontal"]',
18
+ decls: { width: "100%", height: "1px" },
19
+ },
20
+ {
21
+ selector: '&[data-orientation="vertical"]',
22
+ decls: { width: "1px", alignSelf: "stretch", minHeight: "1em" },
23
+ },
24
+ ],
15
25
  });
26
+ exports.spec = spec;
27
+ const config = (0, core_1.specToRecipe)(spec);
16
28
  exports.dividerRecipe = (0, core_1.resolveRecipe)(config);
17
29
  exports.default = config;
@@ -1,20 +1,60 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.progressRecipe = void 0;
3
+ exports.spec = exports.progressRecipe = void 0;
4
4
  const core_1 = require("../../core");
5
5
  const tokens_1 = require("../../tokens");
6
6
  // A progress indicator — a continuous brand-filled track, or a segmented row of
7
7
  // pills (a "Getting started 3/6" bar).
8
- const config = (0, core_1.defineRecipe)({
8
+ const spec = (0, core_1.defineSpec)({
9
9
  name: "progress",
10
- base: `display: block; box-sizing: border-box; height: 6px; border-radius: ${tokens_1.contract.radius.full}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 8%, transparent); overflow: hidden;
11
- & .crisp-progress-fill { display: block; height: 100%; border-radius: ${tokens_1.contract.radius.full}; background: ${tokens_1.contract.bgBrandSolid}; transition: width 0.25s ease; }
12
- &[data-segmented] { display: flex; gap: ${tokens_1.contract.space["1"]}; background: transparent; overflow: visible; }
13
- &[data-segmented] .crisp-progress-seg { flex: 1; height: 100%; border-radius: ${tokens_1.contract.radius.full}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 8%, transparent); transition: background 0.2s; }
14
- &[data-segmented] .crisp-progress-seg[data-filled] { background: ${tokens_1.contract.bgBrandSolid}; }`,
15
- variants: {},
16
- defaultVariants: {},
17
- compoundVariants: [],
10
+ base: {
11
+ display: "block",
12
+ boxSizing: "border-box",
13
+ height: "6px",
14
+ borderRadius: tokens_1.contract.radius.full,
15
+ background: `color-mix(in srgb, ${tokens_1.contract.fgPrimary} 8%, transparent)`,
16
+ overflow: "hidden",
17
+ },
18
+ parts: [
19
+ {
20
+ slot: "fill",
21
+ selector: "& .crisp-progress-fill",
22
+ decls: {
23
+ display: "block",
24
+ height: "100%",
25
+ borderRadius: tokens_1.contract.radius.full,
26
+ background: tokens_1.contract.bgBrandSolid,
27
+ transition: "width 0.25s ease",
28
+ },
29
+ },
30
+ {
31
+ selector: "&[data-segmented]",
32
+ decls: {
33
+ display: "flex",
34
+ gap: tokens_1.contract.space["1"],
35
+ background: "transparent",
36
+ overflow: "visible",
37
+ },
38
+ },
39
+ {
40
+ slot: "seg",
41
+ selector: "&[data-segmented] .crisp-progress-seg",
42
+ decls: {
43
+ flex: 1,
44
+ height: "100%",
45
+ borderRadius: tokens_1.contract.radius.full,
46
+ background: `color-mix(in srgb, ${tokens_1.contract.fgPrimary} 8%, transparent)`,
47
+ transition: "background 0.2s",
48
+ },
49
+ },
50
+ {
51
+ slot: "seg",
52
+ selector: "&[data-segmented] .crisp-progress-seg[data-filled]",
53
+ decls: { background: tokens_1.contract.bgBrandSolid },
54
+ },
55
+ ],
18
56
  });
57
+ exports.spec = spec;
58
+ const config = (0, core_1.specToRecipe)(spec);
19
59
  exports.progressRecipe = (0, core_1.resolveRecipe)(config);
20
60
  exports.default = config;
@@ -1,16 +1,24 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.spinnerRecipe = void 0;
3
+ exports.spec = exports.spinnerRecipe = void 0;
4
4
  const core_1 = require("../../core");
5
5
  const tokens_1 = require("../../tokens");
6
6
  // A spinning ring loading indicator.
7
- const config = (0, core_1.defineRecipe)({
7
+ const spec = (0, core_1.defineSpec)({
8
8
  name: "spinner",
9
- globalCss: `@keyframes crisp-spin { to { transform: rotate(360deg); } }`,
10
- base: `display: inline-block; box-sizing: border-box; width: 16px; height: 16px; border-radius: ${tokens_1.contract.radius.full}; border: 2px solid color-mix(in srgb, ${tokens_1.contract.fgPrimary} 15%, transparent); border-top-color: ${tokens_1.contract.fgMuted}; animation: crisp-spin 0.6s linear infinite;`,
11
- variants: {},
12
- defaultVariants: {},
13
- compoundVariants: [],
9
+ globalCss: "@keyframes crisp-spin { to { transform: rotate(360deg); } }",
10
+ base: {
11
+ display: "inline-block",
12
+ boxSizing: "border-box",
13
+ width: "16px",
14
+ height: "16px",
15
+ borderRadius: tokens_1.contract.radius.full,
16
+ border: `2px solid color-mix(in srgb, ${tokens_1.contract.fgPrimary} 15%, transparent)`,
17
+ borderTopColor: tokens_1.contract.fgMuted,
18
+ animation: "crisp-spin 0.6s linear infinite",
19
+ },
14
20
  });
21
+ exports.spec = spec;
22
+ const config = (0, core_1.specToRecipe)(spec);
15
23
  exports.spinnerRecipe = (0, core_1.resolveRecipe)(config);
16
24
  exports.default = config;
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.usePrefersReducedMotion = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.formValueStyle = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = void 0;
17
+ exports.usePrefersReducedMotion = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.formValueStyle = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = void 0;
18
18
  var cx_1 = require("./cx");
19
19
  Object.defineProperty(exports, "cx", { enumerable: true, get: function () { return cx_1.cx; } });
20
20
  var define_1 = require("./define");
@@ -32,6 +32,9 @@ Object.defineProperty(exports, "Primitive", { enumerable: true, get: function ()
32
32
  var resolve_1 = require("./resolve");
33
33
  Object.defineProperty(exports, "resolveRecipe", { enumerable: true, get: function () { return resolve_1.resolveRecipe; } });
34
34
  __exportStar(require("./rtl"), exports);
35
+ var spec_1 = require("./spec");
36
+ Object.defineProperty(exports, "defineSpec", { enumerable: true, get: function () { return spec_1.defineSpec; } });
37
+ Object.defineProperty(exports, "specToRecipe", { enumerable: true, get: function () { return spec_1.specToRecipe; } });
35
38
  var use_focus_after_removal_1 = require("./use-focus-after-removal");
36
39
  Object.defineProperty(exports, "useFocusAfterRemoval", { enumerable: true, get: function () { return use_focus_after_removal_1.useFocusAfterRemoval; } });
37
40
  var use_form_reset_1 = require("./use-form-reset");
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.declsToCss = declsToCss;
4
+ exports.defineSpec = defineSpec;
5
+ exports.specToRecipe = specToRecipe;
6
+ const kebab = (p) => p.startsWith("--") ? p : p.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
7
+ /** `{ flexShrink: 0 }` -> `flex-shrink: 0;`, in insertion order. */
8
+ function declsToCss(decls) {
9
+ return Object.entries(decls)
10
+ .map(([prop, value]) => `${kebab(prop)}: ${value};`)
11
+ .join(" ");
12
+ }
13
+ /**
14
+ * `const` type parameter, not a plain `ComponentSpec` return: widening the
15
+ * variant keys here loses the literal union that `VariantProps<typeof recipe>`
16
+ * hands a component, and `BadgeProps["tone"]` silently degrades from
17
+ * `"neutral" | "brand" | …` to `string`. That is a public API regression that
18
+ * typechecks everywhere except at the call sites it was meant to protect.
19
+ */
20
+ function defineSpec(spec) {
21
+ return spec;
22
+ }
23
+ /**
24
+ * Compile a spec into the RecipeConfig the existing pipeline already takes, so
25
+ * nothing downstream — the CSS generator, the d.ts generator, `resolveRecipe`,
26
+ * the spec guards — has to know a spec exists. That is what keeps the
27
+ * conversion provable: `dist/` must not move, and CI's
28
+ * `git diff --exit-code dist/` is the gate.
29
+ *
30
+ * The formatting here is not cosmetic freedom. It reproduces exactly what the
31
+ * hand-written template strings emit — root declarations space-joined, each
32
+ * nested rule on its own line indented four spaces — because any other choice
33
+ * rewrites every committed stylesheet and buries the conversion's real diff.
34
+ */
35
+ function specToRecipe(spec) {
36
+ const root = declsToCss(spec.base);
37
+ const nested = (spec.parts ?? []).map((p) => `${p.selector} { ${declsToCss(p.decls)} }`);
38
+ const base = nested.length ? `${root}\n ${nested.join("\n ")}` : root;
39
+ // A variant value compiles the same way the root does: declarations first,
40
+ // then any nested rules it contributes, so a variant that restyles a part
41
+ // (Badge's solid dot) stays in the variant rather than leaking into base.
42
+ const compileValue = (v) => {
43
+ const decls = "decls" in v ? v.decls : v;
44
+ const nestedParts = "decls" in v ? (v.parts ?? []) : [];
45
+ const head = declsToCss(decls);
46
+ if (!nestedParts.length)
47
+ return head;
48
+ const tail = nestedParts
49
+ .map((p) => `${p.selector} { ${declsToCss(p.decls)} }`)
50
+ .join("\n ");
51
+ return head ? `${head}\n ${tail}` : tail;
52
+ };
53
+ const variants = {};
54
+ for (const [name, values] of Object.entries(spec.variants ?? {})) {
55
+ variants[name] = {};
56
+ for (const [value, v] of Object.entries(values)) {
57
+ variants[name][value] = compileValue(v);
58
+ }
59
+ }
60
+ return {
61
+ name: spec.name,
62
+ base,
63
+ variants: variants,
64
+ defaultVariants: (spec.defaultVariants ?? {}),
65
+ compoundVariants: (spec.compoundVariants ?? []).map(({ decls, ...sel }) => ({
66
+ ...sel,
67
+ css: declsToCss(decls),
68
+ })),
69
+ ...(spec.globalCss ? { globalCss: spec.globalCss } : {}),
70
+ };
71
+ }
@@ -1,57 +1,116 @@
1
- import { defineRecipe, resolveRecipe } from "../../core/index.js";
1
+ import { defineSpec, resolveRecipe, specToRecipe } from "../../core/index.js";
2
2
  import { contract as c } from "../../tokens/index.js";
3
- const config = defineRecipe({
3
+ const ring = (v, pct) => `inset 0 0 0 1px color-mix(in srgb, ${v} ${pct}%, transparent)`;
4
+ const soft = (v, pct) => `color-mix(in srgb, ${v} ${pct}%, transparent)`;
5
+ const spec = defineSpec({
4
6
  name: "badge",
5
- base: `display: inline-flex; align-items: center; justify-content: center; gap: ${c.space["1"]}; font-family: ${c.font.sans}; font-weight: ${c.weight.medium}; letter-spacing: -0.1px; white-space: nowrap;
6
- & .crisp-badge-dot { width: 6px; height: 6px; flex-shrink: 0; border-radius: ${c.radius.full}; background: currentColor; }`,
7
+ base: {
8
+ display: "inline-flex",
9
+ alignItems: "center",
10
+ justifyContent: "center",
11
+ gap: c.space["1"],
12
+ fontFamily: c.font.sans,
13
+ fontWeight: c.weight.medium,
14
+ letterSpacing: "-0.1px",
15
+ whiteSpace: "nowrap",
16
+ },
17
+ parts: [
18
+ {
19
+ slot: "dot",
20
+ selector: "& .crisp-badge-dot",
21
+ decls: {
22
+ width: "6px",
23
+ height: "6px",
24
+ flexShrink: 0,
25
+ borderRadius: c.radius.full,
26
+ background: "currentColor",
27
+ },
28
+ },
29
+ ],
7
30
  variants: {
8
31
  tone: {
9
- neutral: `background: ${c.bgRaised}; color: ${c.fgMuted}; box-shadow: inset 0 0 0 1px ${c.borderDefault};`,
10
- brand: `background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent); color: ${c.bgBrandSolid}; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgBrandSolid} 30%, transparent);`,
11
- success: `background: color-mix(in srgb, ${c.bgSuccess} 14%, transparent); color: ${c.bgSuccess}; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgSuccess} 30%, transparent);`,
12
- warning: `background: color-mix(in srgb, ${c.bgWarning} 16%, transparent); color: ${c.bgWarning}; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgWarning} 32%, transparent);`,
13
- danger: `background: color-mix(in srgb, ${c.bgDanger} 14%, transparent); color: ${c.bgDanger}; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgDanger} 30%, transparent);`,
32
+ neutral: {
33
+ background: c.bgRaised,
34
+ color: c.fgMuted,
35
+ boxShadow: `inset 0 0 0 1px ${c.borderDefault}`,
36
+ },
37
+ brand: {
38
+ background: soft(c.bgBrandSolid, 14),
39
+ color: c.bgBrandSolid,
40
+ boxShadow: ring(c.bgBrandSolid, 30),
41
+ },
42
+ success: {
43
+ background: soft(c.bgSuccess, 14),
44
+ color: c.bgSuccess,
45
+ boxShadow: ring(c.bgSuccess, 30),
46
+ },
47
+ warning: {
48
+ background: soft(c.bgWarning, 16),
49
+ color: c.bgWarning,
50
+ boxShadow: ring(c.bgWarning, 32),
51
+ },
52
+ danger: {
53
+ background: soft(c.bgDanger, 14),
54
+ color: c.bgDanger,
55
+ boxShadow: ring(c.bgDanger, 30),
56
+ },
14
57
  },
15
58
  size: {
16
- small: `height: 16px; padding-inline: ${c.space["1.5"]}; font-size: ${c.text.xs}; border-radius: ${c.radius.sm};`,
17
- medium: `height: 20px; padding-inline: ${c.space["2"]}; font-size: ${c.text.sm}; border-radius: ${c.radius.sm};`,
18
- large: `height: ${c.size.chip}; padding-inline: 9px; font-size: ${c.text.lg}; border-radius: ${c.radius.chip};`,
19
- },
20
- variant: {
21
- soft: ``,
22
- solid: ``,
59
+ small: {
60
+ height: "16px",
61
+ paddingInline: c.space["1.5"],
62
+ fontSize: c.text.xs,
63
+ borderRadius: c.radius.sm,
64
+ },
65
+ medium: {
66
+ height: "20px",
67
+ paddingInline: c.space["2"],
68
+ fontSize: c.text.sm,
69
+ borderRadius: c.radius.sm,
70
+ },
71
+ large: {
72
+ height: c.size.chip,
73
+ paddingInline: "9px",
74
+ fontSize: c.text.lg,
75
+ borderRadius: c.radius.chip,
76
+ },
23
77
  },
78
+ variant: { soft: {}, solid: {} },
24
79
  },
25
80
  defaultVariants: { tone: "neutral", size: "medium", variant: "soft" },
26
81
  compoundVariants: [
27
82
  {
28
83
  variant: "solid",
29
84
  tone: "brand",
30
- css: `background: ${c.bgBrandSolid}; color: ${c.fgOnBrand}; box-shadow: none;`,
85
+ decls: {
86
+ background: c.bgBrandSolid,
87
+ color: c.fgOnBrand,
88
+ boxShadow: "none",
89
+ },
31
90
  },
32
91
  {
33
92
  variant: "solid",
34
93
  tone: "success",
35
- css: `background: ${c.bgSuccess}; color: ${c.fgOnTone}; box-shadow: none;`,
94
+ decls: { background: c.bgSuccess, color: c.fgOnTone, boxShadow: "none" },
36
95
  },
37
96
  {
38
97
  variant: "solid",
39
98
  tone: "warning",
40
- css: `background: ${c.bgWarning}; color: ${c.fgOnTone}; box-shadow: none;`,
99
+ decls: { background: c.bgWarning, color: c.fgOnTone, boxShadow: "none" },
41
100
  },
42
101
  {
43
102
  variant: "solid",
44
103
  tone: "danger",
45
- css: `background: ${c.bgDanger}; color: ${c.fgOnTone}; box-shadow: none;`,
104
+ decls: { background: c.bgDanger, color: c.fgOnTone, boxShadow: "none" },
46
105
  },
47
106
  {
48
107
  variant: "solid",
49
108
  tone: "neutral",
50
- css: `background: ${c.fgMuted}; color: ${c.bgSurface}; box-shadow: none;`,
109
+ decls: { background: c.fgMuted, color: c.bgSurface, boxShadow: "none" },
51
110
  },
52
111
  ],
53
112
  });
54
- export const badgeRecipe = Object.assign(resolveRecipe(config), {
55
- config,
56
- });
113
+ const config = specToRecipe(spec);
114
+ export const badgeRecipe = Object.assign(resolveRecipe(config), { config });
115
+ export { spec };
57
116
  export default config;
@@ -1,14 +1,26 @@
1
- import { defineRecipe, resolveRecipe } from "../../core/index.js";
1
+ import { defineSpec, resolveRecipe, specToRecipe } from "../../core/index.js";
2
2
  import { contract as c } from "../../tokens/index.js";
3
3
  // A hairline separator — horizontal (default) or vertical.
4
- const config = defineRecipe({
4
+ const spec = defineSpec({
5
5
  name: "divider",
6
- base: `flex-shrink: 0; border: none; margin: 0; background: ${c.borderSubtle};
7
- &[data-orientation="horizontal"] { width: 100%; height: 1px; }
8
- &[data-orientation="vertical"] { width: 1px; align-self: stretch; min-height: 1em; }`,
9
- variants: {},
10
- defaultVariants: {},
11
- compoundVariants: [],
6
+ base: {
7
+ flexShrink: 0,
8
+ border: "none",
9
+ margin: 0,
10
+ background: c.borderSubtle,
11
+ },
12
+ parts: [
13
+ {
14
+ selector: '&[data-orientation="horizontal"]',
15
+ decls: { width: "100%", height: "1px" },
16
+ },
17
+ {
18
+ selector: '&[data-orientation="vertical"]',
19
+ decls: { width: "1px", alignSelf: "stretch", minHeight: "1em" },
20
+ },
21
+ ],
12
22
  });
23
+ const config = specToRecipe(spec);
13
24
  export const dividerRecipe = resolveRecipe(config);
25
+ export { spec };
14
26
  export default config;
@@ -1,17 +1,57 @@
1
- import { defineRecipe, resolveRecipe } from "../../core/index.js";
1
+ import { defineSpec, resolveRecipe, specToRecipe } from "../../core/index.js";
2
2
  import { contract as c } from "../../tokens/index.js";
3
3
  // A progress indicator — a continuous brand-filled track, or a segmented row of
4
4
  // pills (a "Getting started 3/6" bar).
5
- const config = defineRecipe({
5
+ const spec = defineSpec({
6
6
  name: "progress",
7
- base: `display: block; box-sizing: border-box; height: 6px; border-radius: ${c.radius.full}; background: color-mix(in srgb, ${c.fgPrimary} 8%, transparent); overflow: hidden;
8
- & .crisp-progress-fill { display: block; height: 100%; border-radius: ${c.radius.full}; background: ${c.bgBrandSolid}; transition: width 0.25s ease; }
9
- &[data-segmented] { display: flex; gap: ${c.space["1"]}; background: transparent; overflow: visible; }
10
- &[data-segmented] .crisp-progress-seg { flex: 1; height: 100%; border-radius: ${c.radius.full}; background: color-mix(in srgb, ${c.fgPrimary} 8%, transparent); transition: background 0.2s; }
11
- &[data-segmented] .crisp-progress-seg[data-filled] { background: ${c.bgBrandSolid}; }`,
12
- variants: {},
13
- defaultVariants: {},
14
- compoundVariants: [],
7
+ base: {
8
+ display: "block",
9
+ boxSizing: "border-box",
10
+ height: "6px",
11
+ borderRadius: c.radius.full,
12
+ background: `color-mix(in srgb, ${c.fgPrimary} 8%, transparent)`,
13
+ overflow: "hidden",
14
+ },
15
+ parts: [
16
+ {
17
+ slot: "fill",
18
+ selector: "& .crisp-progress-fill",
19
+ decls: {
20
+ display: "block",
21
+ height: "100%",
22
+ borderRadius: c.radius.full,
23
+ background: c.bgBrandSolid,
24
+ transition: "width 0.25s ease",
25
+ },
26
+ },
27
+ {
28
+ selector: "&[data-segmented]",
29
+ decls: {
30
+ display: "flex",
31
+ gap: c.space["1"],
32
+ background: "transparent",
33
+ overflow: "visible",
34
+ },
35
+ },
36
+ {
37
+ slot: "seg",
38
+ selector: "&[data-segmented] .crisp-progress-seg",
39
+ decls: {
40
+ flex: 1,
41
+ height: "100%",
42
+ borderRadius: c.radius.full,
43
+ background: `color-mix(in srgb, ${c.fgPrimary} 8%, transparent)`,
44
+ transition: "background 0.2s",
45
+ },
46
+ },
47
+ {
48
+ slot: "seg",
49
+ selector: "&[data-segmented] .crisp-progress-seg[data-filled]",
50
+ decls: { background: c.bgBrandSolid },
51
+ },
52
+ ],
15
53
  });
54
+ const config = specToRecipe(spec);
16
55
  export const progressRecipe = resolveRecipe(config);
56
+ export { spec };
17
57
  export default config;
@@ -1,13 +1,21 @@
1
- import { defineRecipe, resolveRecipe } from "../../core/index.js";
1
+ import { defineSpec, resolveRecipe, specToRecipe } from "../../core/index.js";
2
2
  import { contract as c } from "../../tokens/index.js";
3
3
  // A spinning ring loading indicator.
4
- const config = defineRecipe({
4
+ const spec = defineSpec({
5
5
  name: "spinner",
6
- globalCss: `@keyframes crisp-spin { to { transform: rotate(360deg); } }`,
7
- base: `display: inline-block; box-sizing: border-box; width: 16px; height: 16px; border-radius: ${c.radius.full}; border: 2px solid color-mix(in srgb, ${c.fgPrimary} 15%, transparent); border-top-color: ${c.fgMuted}; animation: crisp-spin 0.6s linear infinite;`,
8
- variants: {},
9
- defaultVariants: {},
10
- compoundVariants: [],
6
+ globalCss: "@keyframes crisp-spin { to { transform: rotate(360deg); } }",
7
+ base: {
8
+ display: "inline-block",
9
+ boxSizing: "border-box",
10
+ width: "16px",
11
+ height: "16px",
12
+ borderRadius: c.radius.full,
13
+ border: `2px solid color-mix(in srgb, ${c.fgPrimary} 15%, transparent)`,
14
+ borderTopColor: c.fgMuted,
15
+ animation: "crisp-spin 0.6s linear infinite",
16
+ },
11
17
  });
18
+ const config = specToRecipe(spec);
12
19
  export const spinnerRecipe = resolveRecipe(config);
20
+ export { spec };
13
21
  export default config;
@@ -7,6 +7,7 @@ export { placeFloating, } from "./place-floating.js";
7
7
  export { Primitive } from "./primitive.js";
8
8
  export { resolveRecipe } from "./resolve.js";
9
9
  export * from "./rtl.js";
10
+ export { defineSpec, specToRecipe } from "./spec.js";
10
11
  export { useFocusAfterRemoval } from "./use-focus-after-removal.js";
11
12
  export { useFormReset } from "./use-form-reset.js";
12
13
  export { useLabelAssociation } from "./use-label-association.js";
@@ -0,0 +1,66 @@
1
+ const kebab = (p) => p.startsWith("--") ? p : p.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
2
+ /** `{ flexShrink: 0 }` -> `flex-shrink: 0;`, in insertion order. */
3
+ export function declsToCss(decls) {
4
+ return Object.entries(decls)
5
+ .map(([prop, value]) => `${kebab(prop)}: ${value};`)
6
+ .join(" ");
7
+ }
8
+ /**
9
+ * `const` type parameter, not a plain `ComponentSpec` return: widening the
10
+ * variant keys here loses the literal union that `VariantProps<typeof recipe>`
11
+ * hands a component, and `BadgeProps["tone"]` silently degrades from
12
+ * `"neutral" | "brand" | …` to `string`. That is a public API regression that
13
+ * typechecks everywhere except at the call sites it was meant to protect.
14
+ */
15
+ export function defineSpec(spec) {
16
+ return spec;
17
+ }
18
+ /**
19
+ * Compile a spec into the RecipeConfig the existing pipeline already takes, so
20
+ * nothing downstream — the CSS generator, the d.ts generator, `resolveRecipe`,
21
+ * the spec guards — has to know a spec exists. That is what keeps the
22
+ * conversion provable: `dist/` must not move, and CI's
23
+ * `git diff --exit-code dist/` is the gate.
24
+ *
25
+ * The formatting here is not cosmetic freedom. It reproduces exactly what the
26
+ * hand-written template strings emit — root declarations space-joined, each
27
+ * nested rule on its own line indented four spaces — because any other choice
28
+ * rewrites every committed stylesheet and buries the conversion's real diff.
29
+ */
30
+ export function specToRecipe(spec) {
31
+ const root = declsToCss(spec.base);
32
+ const nested = (spec.parts ?? []).map((p) => `${p.selector} { ${declsToCss(p.decls)} }`);
33
+ const base = nested.length ? `${root}\n ${nested.join("\n ")}` : root;
34
+ // A variant value compiles the same way the root does: declarations first,
35
+ // then any nested rules it contributes, so a variant that restyles a part
36
+ // (Badge's solid dot) stays in the variant rather than leaking into base.
37
+ const compileValue = (v) => {
38
+ const decls = "decls" in v ? v.decls : v;
39
+ const nestedParts = "decls" in v ? (v.parts ?? []) : [];
40
+ const head = declsToCss(decls);
41
+ if (!nestedParts.length)
42
+ return head;
43
+ const tail = nestedParts
44
+ .map((p) => `${p.selector} { ${declsToCss(p.decls)} }`)
45
+ .join("\n ");
46
+ return head ? `${head}\n ${tail}` : tail;
47
+ };
48
+ const variants = {};
49
+ for (const [name, values] of Object.entries(spec.variants ?? {})) {
50
+ variants[name] = {};
51
+ for (const [value, v] of Object.entries(values)) {
52
+ variants[name][value] = compileValue(v);
53
+ }
54
+ }
55
+ return {
56
+ name: spec.name,
57
+ base,
58
+ variants: variants,
59
+ defaultVariants: (spec.defaultVariants ?? {}),
60
+ compoundVariants: (spec.compoundVariants ?? []).map(({ decls, ...sel }) => ({
61
+ ...sel,
62
+ css: declsToCss(decls),
63
+ })),
64
+ ...(spec.globalCss ? { globalCss: spec.globalCss } : {}),
65
+ };
66
+ }
@@ -7,39 +7,39 @@ export type BadgeProps = React.ComponentPropsWithoutRef<"span"> & VariantProps<t
7
7
  dot?: boolean;
8
8
  };
9
9
  export declare const Badge: React.ForwardRefExoticComponent<Omit<React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref"> & VariantProps<import("../..").RecipeFn<{
10
- tone: {
11
- neutral: string;
12
- brand: string;
13
- success: string;
14
- warning: string;
15
- danger: string;
10
+ readonly tone: {
11
+ readonly neutral: string;
12
+ readonly brand: string;
13
+ readonly success: string;
14
+ readonly warning: string;
15
+ readonly danger: string;
16
16
  };
17
- size: {
18
- small: string;
19
- medium: string;
20
- large: string;
17
+ readonly size: {
18
+ readonly small: string;
19
+ readonly medium: string;
20
+ readonly large: string;
21
21
  };
22
- variant: {
23
- soft: string;
24
- solid: string;
22
+ readonly variant: {
23
+ readonly soft: string;
24
+ readonly solid: string;
25
25
  };
26
26
  }> & {
27
27
  config: import("../..").RecipeConfig<{
28
- tone: {
29
- neutral: string;
30
- brand: string;
31
- success: string;
32
- warning: string;
33
- danger: string;
28
+ readonly tone: {
29
+ readonly neutral: string;
30
+ readonly brand: string;
31
+ readonly success: string;
32
+ readonly warning: string;
33
+ readonly danger: string;
34
34
  };
35
- size: {
36
- small: string;
37
- medium: string;
38
- large: string;
35
+ readonly size: {
36
+ readonly small: string;
37
+ readonly medium: string;
38
+ readonly large: string;
39
39
  };
40
- variant: {
41
- soft: string;
42
- solid: string;
40
+ readonly variant: {
41
+ readonly soft: string;
42
+ readonly solid: string;
43
43
  };
44
44
  }>;
45
45
  }> & {
@@ -1,56 +1,180 @@
1
+ declare const spec: {
2
+ readonly name: "badge";
3
+ readonly base: {
4
+ readonly display: "inline-flex";
5
+ readonly alignItems: "center";
6
+ readonly justifyContent: "center";
7
+ readonly gap: `var(--${string})`;
8
+ readonly fontFamily: `var(--${string})`;
9
+ readonly fontWeight: `var(--${string})`;
10
+ readonly letterSpacing: "-0.1px";
11
+ readonly whiteSpace: "nowrap";
12
+ };
13
+ readonly parts: [{
14
+ readonly slot: "dot";
15
+ readonly selector: "& .crisp-badge-dot";
16
+ readonly decls: {
17
+ readonly width: "6px";
18
+ readonly height: "6px";
19
+ readonly flexShrink: 0;
20
+ readonly borderRadius: `var(--${string})`;
21
+ readonly background: "currentColor";
22
+ };
23
+ }];
24
+ readonly variants: {
25
+ readonly tone: {
26
+ readonly neutral: {
27
+ readonly background: `var(--${string})`;
28
+ readonly color: `var(--${string})`;
29
+ readonly boxShadow: `inset 0 0 0 1px var(--${string})`;
30
+ };
31
+ readonly brand: {
32
+ background: string;
33
+ color: `var(--${string})`;
34
+ boxShadow: string;
35
+ };
36
+ readonly success: {
37
+ background: string;
38
+ color: `var(--${string})`;
39
+ boxShadow: string;
40
+ };
41
+ readonly warning: {
42
+ background: string;
43
+ color: `var(--${string})`;
44
+ boxShadow: string;
45
+ };
46
+ readonly danger: {
47
+ background: string;
48
+ color: `var(--${string})`;
49
+ boxShadow: string;
50
+ };
51
+ };
52
+ readonly size: {
53
+ readonly small: {
54
+ readonly height: "16px";
55
+ readonly paddingInline: `var(--${string})`;
56
+ readonly fontSize: `var(--${string})`;
57
+ readonly borderRadius: `var(--${string})`;
58
+ };
59
+ readonly medium: {
60
+ readonly height: "20px";
61
+ readonly paddingInline: `var(--${string})`;
62
+ readonly fontSize: `var(--${string})`;
63
+ readonly borderRadius: `var(--${string})`;
64
+ };
65
+ readonly large: {
66
+ readonly height: `var(--${string})`;
67
+ readonly paddingInline: "9px";
68
+ readonly fontSize: `var(--${string})`;
69
+ readonly borderRadius: `var(--${string})`;
70
+ };
71
+ };
72
+ readonly variant: {
73
+ soft: {};
74
+ solid: {};
75
+ };
76
+ };
77
+ readonly defaultVariants: {
78
+ readonly tone: "neutral";
79
+ readonly size: "medium";
80
+ readonly variant: "soft";
81
+ };
82
+ readonly compoundVariants: [{
83
+ readonly variant: "solid";
84
+ readonly tone: "brand";
85
+ readonly decls: {
86
+ readonly background: `var(--${string})`;
87
+ readonly color: `var(--${string})`;
88
+ readonly boxShadow: "none";
89
+ };
90
+ }, {
91
+ readonly variant: "solid";
92
+ readonly tone: "success";
93
+ readonly decls: {
94
+ readonly background: `var(--${string})`;
95
+ readonly color: `var(--${string})`;
96
+ readonly boxShadow: "none";
97
+ };
98
+ }, {
99
+ readonly variant: "solid";
100
+ readonly tone: "warning";
101
+ readonly decls: {
102
+ readonly background: `var(--${string})`;
103
+ readonly color: `var(--${string})`;
104
+ readonly boxShadow: "none";
105
+ };
106
+ }, {
107
+ readonly variant: "solid";
108
+ readonly tone: "danger";
109
+ readonly decls: {
110
+ readonly background: `var(--${string})`;
111
+ readonly color: `var(--${string})`;
112
+ readonly boxShadow: "none";
113
+ };
114
+ }, {
115
+ readonly variant: "solid";
116
+ readonly tone: "neutral";
117
+ readonly decls: {
118
+ readonly background: `var(--${string})`;
119
+ readonly color: `var(--${string})`;
120
+ readonly boxShadow: "none";
121
+ };
122
+ }];
123
+ };
1
124
  declare const config: import("../..").RecipeConfig<{
2
- tone: {
3
- neutral: string;
4
- brand: string;
5
- success: string;
6
- warning: string;
7
- danger: string;
8
- };
9
- size: {
10
- small: string;
11
- medium: string;
12
- large: string;
13
- };
14
- variant: {
15
- soft: string;
16
- solid: string;
125
+ readonly tone: {
126
+ readonly neutral: string;
127
+ readonly brand: string;
128
+ readonly success: string;
129
+ readonly warning: string;
130
+ readonly danger: string;
131
+ };
132
+ readonly size: {
133
+ readonly small: string;
134
+ readonly medium: string;
135
+ readonly large: string;
136
+ };
137
+ readonly variant: {
138
+ readonly soft: string;
139
+ readonly solid: string;
17
140
  };
18
141
  }>;
19
142
  export declare const badgeRecipe: import("../..").RecipeFn<{
20
- tone: {
21
- neutral: string;
22
- brand: string;
23
- success: string;
24
- warning: string;
25
- danger: string;
26
- };
27
- size: {
28
- small: string;
29
- medium: string;
30
- large: string;
31
- };
32
- variant: {
33
- soft: string;
34
- solid: string;
143
+ readonly tone: {
144
+ readonly neutral: string;
145
+ readonly brand: string;
146
+ readonly success: string;
147
+ readonly warning: string;
148
+ readonly danger: string;
149
+ };
150
+ readonly size: {
151
+ readonly small: string;
152
+ readonly medium: string;
153
+ readonly large: string;
154
+ };
155
+ readonly variant: {
156
+ readonly soft: string;
157
+ readonly solid: string;
35
158
  };
36
159
  }> & {
37
160
  config: import("../..").RecipeConfig<{
38
- tone: {
39
- neutral: string;
40
- brand: string;
41
- success: string;
42
- warning: string;
43
- danger: string;
44
- };
45
- size: {
46
- small: string;
47
- medium: string;
48
- large: string;
49
- };
50
- variant: {
51
- soft: string;
52
- solid: string;
161
+ readonly tone: {
162
+ readonly neutral: string;
163
+ readonly brand: string;
164
+ readonly success: string;
165
+ readonly warning: string;
166
+ readonly danger: string;
167
+ };
168
+ readonly size: {
169
+ readonly small: string;
170
+ readonly medium: string;
171
+ readonly large: string;
172
+ };
173
+ readonly variant: {
174
+ readonly soft: string;
175
+ readonly solid: string;
53
176
  };
54
177
  }>;
55
178
  };
179
+ export { spec };
56
180
  export default config;
@@ -1,3 +1,27 @@
1
- declare const config: import("../..").RecipeConfig<{}>;
2
- export declare const dividerRecipe: import("../..").RecipeFn<{}>;
1
+ declare const spec: {
2
+ readonly name: "divider";
3
+ readonly base: {
4
+ readonly flexShrink: 0;
5
+ readonly border: "none";
6
+ readonly margin: 0;
7
+ readonly background: `var(--${string})`;
8
+ };
9
+ readonly parts: [{
10
+ readonly selector: "&[data-orientation=\"horizontal\"]";
11
+ readonly decls: {
12
+ readonly width: "100%";
13
+ readonly height: "1px";
14
+ };
15
+ }, {
16
+ readonly selector: "&[data-orientation=\"vertical\"]";
17
+ readonly decls: {
18
+ readonly width: "1px";
19
+ readonly alignSelf: "stretch";
20
+ readonly minHeight: "1em";
21
+ };
22
+ }];
23
+ };
24
+ declare const config: import("../..").RecipeConfig<Record<string, Record<string, string>>>;
25
+ export declare const dividerRecipe: import("../..").RecipeFn<Record<string, Record<string, string>>>;
26
+ export { spec };
3
27
  export default config;
@@ -1,3 +1,50 @@
1
- declare const config: import("../..").RecipeConfig<{}>;
2
- export declare const progressRecipe: import("../..").RecipeFn<{}>;
1
+ declare const spec: {
2
+ readonly name: "progress";
3
+ readonly base: {
4
+ readonly display: "block";
5
+ readonly boxSizing: "border-box";
6
+ readonly height: "6px";
7
+ readonly borderRadius: `var(--${string})`;
8
+ readonly background: `color-mix(in srgb, var(--${string}) 8%, transparent)`;
9
+ readonly overflow: "hidden";
10
+ };
11
+ readonly parts: [{
12
+ readonly slot: "fill";
13
+ readonly selector: "& .crisp-progress-fill";
14
+ readonly decls: {
15
+ readonly display: "block";
16
+ readonly height: "100%";
17
+ readonly borderRadius: `var(--${string})`;
18
+ readonly background: `var(--${string})`;
19
+ readonly transition: "width 0.25s ease";
20
+ };
21
+ }, {
22
+ readonly selector: "&[data-segmented]";
23
+ readonly decls: {
24
+ readonly display: "flex";
25
+ readonly gap: `var(--${string})`;
26
+ readonly background: "transparent";
27
+ readonly overflow: "visible";
28
+ };
29
+ }, {
30
+ readonly slot: "seg";
31
+ readonly selector: "&[data-segmented] .crisp-progress-seg";
32
+ readonly decls: {
33
+ readonly flex: 1;
34
+ readonly height: "100%";
35
+ readonly borderRadius: `var(--${string})`;
36
+ readonly background: `color-mix(in srgb, var(--${string}) 8%, transparent)`;
37
+ readonly transition: "background 0.2s";
38
+ };
39
+ }, {
40
+ readonly slot: "seg";
41
+ readonly selector: "&[data-segmented] .crisp-progress-seg[data-filled]";
42
+ readonly decls: {
43
+ background: `var(--${string})`;
44
+ };
45
+ }];
46
+ };
47
+ declare const config: import("../..").RecipeConfig<Record<string, Record<string, string>>>;
48
+ export declare const progressRecipe: import("../..").RecipeFn<Record<string, Record<string, string>>>;
49
+ export { spec };
3
50
  export default config;
@@ -1,3 +1,18 @@
1
- declare const config: import("../..").RecipeConfig<{}>;
2
- export declare const spinnerRecipe: import("../..").RecipeFn<{}>;
1
+ declare const spec: {
2
+ readonly name: "spinner";
3
+ readonly globalCss: "@keyframes crisp-spin { to { transform: rotate(360deg); } }";
4
+ readonly base: {
5
+ readonly display: "inline-block";
6
+ readonly boxSizing: "border-box";
7
+ readonly width: "16px";
8
+ readonly height: "16px";
9
+ readonly borderRadius: `var(--${string})`;
10
+ readonly border: `2px solid color-mix(in srgb, var(--${string}) 15%, transparent)`;
11
+ readonly borderTopColor: `var(--${string})`;
12
+ readonly animation: "crisp-spin 0.6s linear infinite";
13
+ };
14
+ };
15
+ declare const config: import("../..").RecipeConfig<Record<string, Record<string, string>>>;
16
+ export declare const spinnerRecipe: import("../..").RecipeFn<Record<string, Record<string, string>>>;
17
+ export { spec };
3
18
  export default config;
@@ -7,6 +7,7 @@ export { type FloatingAlign, type PlaceFloatingOptions, placeFloating, } from ".
7
7
  export { Primitive } from "./primitive";
8
8
  export { resolveRecipe } from "./resolve";
9
9
  export * from "./rtl";
10
+ export { defineSpec, specToRecipe } from "./spec";
10
11
  export type * from "./types";
11
12
  export { useFocusAfterRemoval } from "./use-focus-after-removal";
12
13
  export { useFormReset } from "./use-form-reset";
@@ -0,0 +1,83 @@
1
+ import type { RecipeConfig } from "./types";
2
+ /**
3
+ * A component spec: the layer above a recipe, where style intent is DATA
4
+ * rather than a CSS string.
5
+ *
6
+ * ADR 0002 §2. A string cannot be asked a question — `build/emit-lynx.mjs`
7
+ * mangled every at-rule and the Spinner never span (#174) because the only
8
+ * thing it could do to CSS text was rewrite it and hope. A spec is a list of
9
+ * properties, so a target that cannot express one can REFUSE it and fail the
10
+ * build, which is the difference between a broken build and a silently wrong
11
+ * one.
12
+ *
13
+ * Two deliberate limits:
14
+ *
15
+ * - `parts` is an ORDERED array, not a map of slots. The emitted CSS depends on
16
+ * rule order (`&[data-segmented] .seg` must follow `&[data-segmented]`), and
17
+ * a map of named slots cannot express an interleaving of child-slot rules and
18
+ * root state rules. The `slot` field carries the name a non-CSS target needs
19
+ * for a declared part; `selector` carries what the web needs.
20
+ * - Values stay `string | number`. Resolving a token to its `var(--…)` is the
21
+ * contract's job, already typed, and re-encoding token references here would
22
+ * be a second source of truth for values that lesson 31 says will drift.
23
+ */
24
+ export type SpecValue = string | number;
25
+ /** Declarations in camelCase, the way a native style object spells them. */
26
+ export type SpecDecls = Record<string, SpecValue>;
27
+ export type SpecPart = {
28
+ /** Name of the declared part, for targets with no CSS selectors. */
29
+ slot?: string;
30
+ /** The nested selector, relative to the component root (`&` is the root). */
31
+ selector: string;
32
+ decls: SpecDecls;
33
+ };
34
+ /** One variant value's declarations, plus any nested rules it adds. */
35
+ export type SpecVariantValue = SpecDecls | {
36
+ decls: SpecDecls;
37
+ parts?: SpecPart[];
38
+ };
39
+ export type ComponentSpec = {
40
+ name: string;
41
+ /** Declarations on the component root. */
42
+ base: SpecDecls;
43
+ /** Nested rules, in emission order. */
44
+ parts?: SpecPart[];
45
+ /** variant -> value -> declarations. */
46
+ variants?: Record<string, Record<string, SpecVariantValue>>;
47
+ defaultVariants?: Record<string, string | boolean>;
48
+ /** Selections that apply together, in emission order. */
49
+ compoundVariants?: Array<Record<string, unknown> & {
50
+ decls: SpecDecls;
51
+ }>;
52
+ /** Raw CSS that must sit outside the component rule (`@keyframes`). */
53
+ globalCss?: string;
54
+ };
55
+ /** `{ flexShrink: 0 }` -> `flex-shrink: 0;`, in insertion order. */
56
+ export declare function declsToCss(decls: SpecDecls): string;
57
+ /**
58
+ * `const` type parameter, not a plain `ComponentSpec` return: widening the
59
+ * variant keys here loses the literal union that `VariantProps<typeof recipe>`
60
+ * hands a component, and `BadgeProps["tone"]` silently degrades from
61
+ * `"neutral" | "brand" | …` to `string`. That is a public API regression that
62
+ * typechecks everywhere except at the call sites it was meant to protect.
63
+ */
64
+ export declare function defineSpec<const S extends ComponentSpec>(spec: S): S;
65
+ /** The recipe variant record a spec implies: same keys, values compiled to CSS. */
66
+ export type SpecVariants<S extends ComponentSpec> = S["variants"] extends Record<string, Record<string, SpecVariantValue>> ? {
67
+ [K in keyof S["variants"]]: {
68
+ [P in keyof S["variants"][K]]: string;
69
+ };
70
+ } : Record<string, Record<string, string>>;
71
+ /**
72
+ * Compile a spec into the RecipeConfig the existing pipeline already takes, so
73
+ * nothing downstream — the CSS generator, the d.ts generator, `resolveRecipe`,
74
+ * the spec guards — has to know a spec exists. That is what keeps the
75
+ * conversion provable: `dist/` must not move, and CI's
76
+ * `git diff --exit-code dist/` is the gate.
77
+ *
78
+ * The formatting here is not cosmetic freedom. It reproduces exactly what the
79
+ * hand-written template strings emit — root declarations space-joined, each
80
+ * nested rule on its own line indented four spaces — because any other choice
81
+ * rewrites every committed stylesheet and buries the conversion's real diff.
82
+ */
83
+ export declare function specToRecipe<const S extends ComponentSpec>(spec: S): RecipeConfig<SpecVariants<S>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -422,6 +422,8 @@
422
422
  "lynx": "bun build/lynx-lint.mjs",
423
423
  "lynx:emit": "bun build/emit-lynx.mjs",
424
424
  "ssr": "bun scripts/ssr-check.tsx",
425
+ "visual": "bun scripts/visual-page.tsx && playwright test",
426
+ "visual:baseline": "bun run build && bun scripts/visual-page.tsx && docker run --rm -v \"$PWD\":/w -w /w mcr.microsoft.com/playwright:v1.63.0-noble npx playwright test --update-snapshots",
425
427
  "contrast": "bun scripts/contrast-page.tsx",
426
428
  "build:esm": "tsc -p tsconfig.esm.json && bun scripts/fix-esm-extensions.mjs",
427
429
  "build:cjs": "tsc -p tsconfig.cjs.json && bun scripts/finish-cjs.mjs",
@@ -450,6 +452,7 @@
450
452
  "devDependencies": {
451
453
  "@biomejs/biome": "^2.5.10",
452
454
  "@happy-dom/global-registrator": "^20.11.6",
455
+ "@playwright/test": "^1.63.0",
453
456
  "@testing-library/jest-dom": "^7.0.1",
454
457
  "@testing-library/react": "^16.3.2",
455
458
  "@testing-library/user-event": "^14.6.6",