@panphora/clayjs 1.8.2 → 1.9.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,9 +1,10 @@
1
1
  // The frame every ClayJS dialog draws: a backdrop, a Bevel panel, an optional corner
2
2
  // close. It holds no behaviour. The modal and the crop adapter own focus, Escape,
3
3
  // backdrop clicks and settling, so each keeps the rules it already had.
4
- import { set } from "../lib/hostile-css.js";
5
- import { bevelBox, bevelSurface, bevelText, pageScheme, protectIcon } from "./bevel-controls.js";
4
+ import { style, set } from "../lib/hostile-css.js";
5
+ import { bevelBox, bevelSurface, bevelText, pageScheme, protectIcon, restyleBox } from "./bevel-controls.js";
6
6
  import { TOKENS, FONT_SANS, RULES, GLYPHS } from "./bevel.js";
7
+ import { themed, tokenRulesFor, partRules, markParts } from "./theme-parts.js";
7
8
 
8
9
  // A frame's own way to settle itself. The modal is a singleton and clears every frame
9
10
  // when it opens; a frame it does not own (the crop dialog) registers here, so it is
@@ -35,45 +36,88 @@ const LINE = `1px solid ${TOKENS["line-2"]}`;
35
36
 
36
37
  // The close: a column at the header's right edge in the button face, or a 62px square
37
38
  // at the panel's corner when there is no header.
38
- function dialogClose(label, inHeader) {
39
+ function dialogClose(label, inHeader, theme = null, names = [], iconNames = []) {
39
40
  const hover = RULES.dialogCloseHover[0].slice("background:".length);
40
- const b = bevelBox("button", [
41
+ const base = [
41
42
  "position:absolute", "top:-1px", "right:-1px", "z-index:2",
42
43
  "display:grid", "place-items:center", inHeader ? "height:calc(100% + 1px)" : "height:62px",
43
44
  "margin:0", "padding:0", "border:0", ...RULES.dialogClose, ...(inHeader ? [] : [`border-bottom:${LINE}`]),
44
45
  "cursor:pointer", "outline:none",
45
- ]);
46
+ ];
47
+ const b = bevelBox("button", base);
46
48
  b.type = "button";
47
49
  b.setAttribute("aria-label", label);
48
50
  b.title = label;
49
51
  b.innerHTML = GLYPHS.dialogClose;
50
- protectIcon(b.firstElementChild, 16);
51
- b.addEventListener("pointerenter", () => set(b, "background", hover));
52
- b.addEventListener("pointerleave", () => set(b, "background", TOKENS.face));
53
- b.addEventListener("focus", () => { set(b, "outline", `2px solid ${TOKENS.brass}`); set(b, "outline-offset", "-4px"); });
54
- b.addEventListener("blur", () => set(b, "outline", "none"));
52
+ if (!themed(theme)) {
53
+ protectIcon(b.firstElementChild, 16);
54
+ b.addEventListener("pointerenter", () => set(b, "background", hover));
55
+ b.addEventListener("pointerleave", () => set(b, "background", TOKENS.face));
56
+ b.addEventListener("focus", () => { set(b, "outline", `2px solid ${TOKENS.brass}`); set(b, "outline-offset", "-4px"); });
57
+ b.addEventListener("blur", () => set(b, "outline", "none"));
58
+ return b;
59
+ }
60
+ const allNames = ["close", ...names];
61
+ const icon = ["icon", ...iconNames];
62
+ markParts(b, theme, allNames);
63
+ markParts(b.firstElementChild, theme, icon);
64
+ const flags = { hover: false, focus: false, pressed: false };
65
+ const paint = () => {
66
+ const act = { hover: flags.hover, active: flags.pressed, focus: flags.focus };
67
+ const svg = b.firstElementChild;
68
+ svg.style.cssText = "";
69
+ protectIcon(svg, 16);
70
+ style(svg, partRules(theme, icon, act));
71
+ restyleBox(b, [
72
+ ...base,
73
+ ...(flags.hover ? [`background:${hover}`] : []),
74
+ ...(flags.focus ? [`outline:2px solid ${TOKENS.brass}`, "outline-offset:-4px"] : []),
75
+ ...tokenRulesFor(theme, "close", act),
76
+ ...partRules(theme, allNames, act),
77
+ ]);
78
+ };
79
+ b.addEventListener("pointerenter", () => { flags.hover = true; paint(); });
80
+ b.addEventListener("pointerleave", () => { flags.hover = false; flags.pressed = false; paint(); });
81
+ b.addEventListener("pointerdown", (e) => {
82
+ if (e.button !== undefined && e.button !== 0) return;
83
+ flags.pressed = true;
84
+ paint();
85
+ });
86
+ b.addEventListener("pointerup", () => { flags.pressed = false; paint(); });
87
+ b.addEventListener("focus", () => { flags.focus = true; paint(); });
88
+ b.addEventListener("blur", () => { flags.focus = false; flags.pressed = false; paint(); });
89
+ paint();
55
90
  return b;
56
91
  }
57
92
 
58
- export function bevelDialog({ zIndex = "100", width = "600px", closable = false, titled = false } = {}) {
59
- const root = bevelBox("div", ["display:block"]);
93
+ // `theme` is a captured resolveTheme() result, or null for the exact Bevel look.
94
+ // `surface` names the dialog for part overrides: "modal" uses dialog.* parts only;
95
+ // "people" and "crop" add people.* or crop.* after them, so those win.
96
+ export function bevelDialog({ zIndex = "100", width = "600px", closable = false, titled = false, theme = null, surface = "modal" } = {}) {
97
+ const on = themed(theme);
98
+ const t = on ? theme.tokens : {};
99
+ const S = t.spacing;
100
+ const names = (part) => (surface === "modal" ? [`dialog.${part}`] : [`dialog.${part}`, `${surface}.${part}`]);
101
+
102
+ const root = bevelBox("div", ["display:block"], { theme, parts: names("root") });
60
103
  root.setAttribute("data-clay-modal", "");
104
+ if (on) root.setAttribute("data-clay-ui", surface);
61
105
  root.setAttribute("aria-hidden", "true");
62
- set(root, "color-scheme", pageScheme());
106
+ set(root, "color-scheme", on ? theme.scheme : pageScheme());
63
107
 
64
108
  const overlay = bevelBox("div", [
65
109
  "position:fixed", "inset:0", `z-index:${zIndex}`, "overflow:auto",
66
110
  "display:flex", "align-items:flex-start", "justify-content:center",
67
111
  "padding:min(96px, 10vh) 16px 16px",
68
112
  `background:color-mix(in srgb, ${TOKENS.ground} 72%, transparent)`,
69
- ]);
113
+ ], { theme, parts: names("overlay"), role: "overlay" });
70
114
  overlay.tabIndex = -1;
71
115
 
72
116
  const panel = bevelSurface("form", [
73
117
  "position:relative", "display:flex", "flex-direction:column", "margin:0",
74
118
  `width:min(${width}, 100%)`, "max-height:calc(100dvh - min(96px, 10vh) - 16px)",
75
119
  ...RULES.dialogPanel,
76
- ]);
120
+ ], { theme, parts: names("panel") });
77
121
  panel.setAttribute("role", "dialog");
78
122
  panel.setAttribute("aria-modal", "true");
79
123
 
@@ -83,8 +127,10 @@ export function bevelDialog({ zIndex = "100", width = "600px", closable = false,
83
127
  header = bevelBox("div", [
84
128
  "position:relative", "display:block", "flex:none",
85
129
  `padding:20px ${closable ? "68px" : "22px"} 16px 22px`, ...RULES.dialogHeader,
86
- ]);
87
- heading = bevelText("div", ["display:block", ...RULES.dialogHeading, "overflow-wrap:anywhere"]);
130
+ ...(t.border ? [`border-bottom-color:${t.border}`] : []),
131
+ ...(S ? [`padding:${S} ${closable ? `calc(${S} + 46px)` : S} calc(${S} * .7) ${S}`] : []),
132
+ ], { theme, parts: names("header") });
133
+ heading = bevelText("div", ["display:block", ...RULES.dialogHeading, "overflow-wrap:anywhere"], undefined, { theme, parts: names("title"), role: "heading" });
88
134
  heading.setAttribute("role", "heading");
89
135
  heading.setAttribute("aria-level", "2");
90
136
  header.append(heading);
@@ -95,16 +141,19 @@ export function bevelDialog({ zIndex = "100", width = "600px", closable = false,
95
141
  "display:block", "flex:1 1 auto", "min-height:0", "overflow-y:auto", "overflow-x:hidden",
96
142
  `padding:20px ${closable && !titled ? "84px" : "22px"} 20px 22px`,
97
143
  "overflow-wrap:anywhere", `font:14.5px/1.55 ${FONT_SANS}`,
98
- ]);
144
+ ...(S ? [`padding:${S} ${closable && !titled ? `calc(${S} + 62px)` : S} ${S} ${S}`] : []),
145
+ ], undefined, { theme, parts: names("body") });
99
146
  const footer = bevelBox("div", [
100
147
  "display:flex", "flex:none", "flex-wrap:wrap", "justify-content:flex-end", "align-items:center", ...RULES.dialogFooter,
101
- ]);
148
+ ...(t.border ? [`border-top-color:${t.border}`] : []),
149
+ ...(S ? [`padding:calc(${S} * .65) ${S}`, `gap:calc(${S} / 2)`] : []),
150
+ ], { theme, parts: [...names("footer"), ...names("actions")] });
102
151
  panel.append(body, footer);
103
152
 
104
- const close = closable ? dialogClose("Close", titled) : null;
153
+ const close = closable ? dialogClose("Close", titled, theme, names("close"), names("closeIcon")) : null;
105
154
  if (close) (header || panel).append(close);
106
155
 
107
156
  overlay.append(panel);
108
157
  root.append(overlay);
109
- return { root, overlay, panel, header, heading, body, footer, close };
158
+ return { root, overlay, panel, header, heading, body, footer, close, theme };
110
159
  }
package/src/ui/dialogs.js CHANGED
@@ -1,18 +1,20 @@
1
- import themodal from "./modal.js";
1
+ import themodal, { SURFACE_THEME } from "./modal.js";
2
2
  import onDomReady from "../lib/dom-ready.js";
3
3
  import toast from "./toast.js";
4
4
  import copyToClipboard from "../utils/copy-to-clipboard.js";
5
5
  import { bevelBox, bevelButton, bevelInput, bevelText } from "./bevel-controls.js";
6
6
  import { TOKENS, FONT_SANS, FONT_MONO } from "./bevel.js";
7
+ import { resolveTheme } from "./theme.js";
7
8
 
8
9
  // Caller markup (promptText, extraContent, tell's paragraphs) goes in as it is: those
9
10
  // are markup by design, and callers pass elements through them. Only the frame around
10
11
  // it is ClayJS's to style. The prompt is the dialog's title, as in the dashboard.
11
12
  function createModal(promptText, yesCallback, extraContent = "", includeInput = false, defaultValue = "", yesLabel = "OK") {
12
- const content = bevelBox("div", ["display:flex", "flex-direction:column", "gap:14px", `font:14.5px/1.55 ${FONT_SANS}`, `color:${TOKENS["ink-2"]}`]);
13
+ const theme = resolveTheme();
14
+ const content = bevelBox("div", ["display:flex", "flex-direction:column", "gap:14px", `font:14.5px/1.55 ${FONT_SANS}`, `color:${TOKENS["ink-2"]}`], { theme, parts: ["dialog.content"], role: "text" });
13
15
  let input = null;
14
16
  if (includeInput) {
15
- input = bevelInput("input", { rules: ["display:block", "width:100%", "margin:0"] });
17
+ input = bevelInput("input", { rules: ["display:block", "width:100%", "margin:0"], theme, parts: ["dialog.input"] });
16
18
  input.setAttribute("value", String(defaultValue));
17
19
  input.required = true;
18
20
  content.append(input);
@@ -22,6 +24,7 @@ function createModal(promptText, yesCallback, extraContent = "", includeInput =
22
24
  themodal.title = promptText;
23
25
  themodal.html = includeInput || extraContent ? content : "";
24
26
  themodal.width = "440px";
27
+ themodal[SURFACE_THEME] = theme;
25
28
  themodal.closeHtml = "x";
26
29
  themodal.no = "Cancel";
27
30
  themodal.yes = yesLabel;
@@ -95,9 +98,10 @@ export function consent(promptText, yesCallback, extraContent = "") {
95
98
  * @returns {Promise} Resolves when user confirms, rejects on close
96
99
  */
97
100
  export function tell(promptText, ...content) {
98
- const box = bevelBox("div", ["display:flex", "flex-direction:column", "gap:12px"]);
101
+ const theme = resolveTheme();
102
+ const box = bevelBox("div", ["display:flex", "flex-direction:column", "gap:12px"], { theme, parts: ["dialog.content"] });
99
103
  for (const c of content) {
100
- const paragraph = bevelText("div", [`font:14.5px/1.55 ${FONT_SANS}`, `color:${TOKENS["ink-2"]}`, "overflow-wrap:anywhere"]);
104
+ const paragraph = bevelText("div", [`font:14.5px/1.55 ${FONT_SANS}`, `color:${TOKENS["ink-2"]}`, "overflow-wrap:anywhere"], undefined, { theme });
101
105
  paragraph.innerHTML = c;
102
106
  box.append(paragraph);
103
107
  }
@@ -105,6 +109,7 @@ export function tell(promptText, ...content) {
105
109
  themodal.title = promptText;
106
110
  themodal.html = content.length ? box : "";
107
111
  themodal.width = "470px";
112
+ themodal[SURFACE_THEME] = theme;
108
113
  themodal.closeHtml = "x";
109
114
  themodal.yes = "OK";
110
115
 
@@ -136,17 +141,19 @@ export function tell(promptText, ...content) {
136
141
  * Callers style their own container.
137
142
  */
138
143
  export function snippet(title, content, extraContent = '') {
139
- const box = bevelBox("div", ["display:block"]);
144
+ const theme = resolveTheme();
145
+ const box = bevelBox("div", ["display:block"], { theme, parts: ["dialog.content"] });
140
146
  const well = bevelBox("div", [
141
147
  "display:block", "max-width:100%", "overflow-x:auto", "margin:0 0 14px", "padding:14px 16px",
142
148
  `background:${TOKENS.sunk}`, `border:1px solid ${TOKENS["line-2"]}`,
143
- ]);
144
- const pre = bevelText("pre", ["display:block", "margin:0", "white-space:nowrap", `font:13px/1.6 ${FONT_MONO}`]);
149
+ ], { theme, parts: ["dialog.well"], role: "well" });
150
+ const pre = bevelText("pre", ["display:block", "margin:0", "white-space:nowrap", `font:13px/1.6 ${FONT_MONO}`], undefined, { theme, parts: ["dialog.code"], role: "code" });
145
151
  pre.innerHTML = content;
146
152
  well.append(pre);
147
153
  const copy = bevelButton("Copy", {
148
154
  small: true,
149
155
  extra: ["margin:0 0 14px"],
156
+ theme, parts: ["dialog.button", "dialog.copyButton"], labelParts: ["dialog.buttonLabel"],
150
157
  onClick: () => {
151
158
  copyToClipboard(content);
152
159
  toast('Copied to clipboard!', 'success');
@@ -158,6 +165,7 @@ export function snippet(title, content, extraContent = '') {
158
165
  themodal.title = title;
159
166
  themodal.html = box;
160
167
  themodal.width = "540px";
168
+ themodal[SURFACE_THEME] = theme;
161
169
  themodal.closeHtml = "x";
162
170
  themodal.yes = '';
163
171
 
package/src/ui/index.js CHANGED
@@ -2,9 +2,12 @@ import toast, { toastPersistent, dismissPersistent } from "./toast.js";
2
2
  import themodal from "./modal.js";
3
3
  import { ask, consent, tell, snippet } from "./dialogs.js";
4
4
  import { saveFeedback } from "../core/save-feedback.js";
5
+ import { initTheme } from "./theme.js";
5
6
 
6
7
  // Attach the public surface explicitly (§2.3). We do NOT rely on toast.js /
7
8
  // dialogs.js evaluation side effects — their window auto-exports are stripped.
9
+ initTheme();
10
+
8
11
  const clay = (window.clay = window.clay || {});
9
12
  clay.toast = toast;
10
13
  clay.toastPersistent = toastPersistent;
package/src/ui/modal.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { bevelButton, setShown } from "./bevel-controls.js";
2
2
  import { set } from "../lib/hostile-css.js";
3
3
  import { bevelDialog, dismissOf } from "./bevel-dialog.js";
4
+ import { resolveTheme } from "./theme.js";
5
+
6
+ // The theme one open() draws with. Internal: only dialogs.js sets it, so no page can
7
+ // leave a value behind that makes the next open() throw.
8
+ export const SURFACE_THEME = Symbol("clay.surfaceTheme");
4
9
 
5
10
  /*
6
11
 
@@ -299,6 +304,7 @@ const themodal = (() => {
299
304
  let closeHtml = "";
300
305
  let title = "";
301
306
  let width = "";
307
+ let theme;
302
308
 
303
309
  let enableClickOutsideCloses = true;
304
310
  let disableScroll = true;
@@ -347,15 +353,17 @@ const themodal = (() => {
347
353
  // Expose this modal's dismiss so a later open()/close can settle it.
348
354
  this._dismiss = dismiss;
349
355
 
350
- const shell = bevelDialog({ zIndex, closable: !!closeHtml, titled: !!title, ...(width ? { width } : {}) });
356
+ const surfaceTheme = theme === undefined ? resolveTheme() : theme;
357
+ const shell = bevelDialog({ zIndex, closable: !!closeHtml, titled: !!title, ...(width ? { width } : {}), theme: surfaceTheme });
351
358
  const modalRootElem = shell.root;
352
359
  const modalOverlayElem = shell.overlay;
353
360
  const modalContainerElem = shell.panel;
354
361
  const modalContentElem = shell.body;
355
362
  const modalButtonsElem = shell.footer;
356
363
  const modalCloseElem = shell.close;
357
- const modalNoElem = bevelButton('');
358
- const modalYesElem = bevelButton('', { variant: 'primary' });
364
+ const buttonParts = { theme: surfaceTheme, parts: ['dialog.button'], labelParts: ['dialog.buttonLabel'] };
365
+ const modalNoElem = bevelButton('', buttonParts);
366
+ const modalYesElem = bevelButton('', { variant: 'primary', ...buttonParts });
359
367
  modalYesElem.type = 'submit';
360
368
  modalButtonsElem.append(modalNoElem, modalYesElem);
361
369
 
@@ -468,6 +476,7 @@ const themodal = (() => {
468
476
  closeHtml = "";
469
477
  title = "";
470
478
  width = "";
479
+ theme = undefined;
471
480
 
472
481
  // reset to defaults
473
482
  enableClickOutsideCloses = true;
@@ -528,6 +537,12 @@ const themodal = (() => {
528
537
  set width(newVal) {
529
538
  width = newVal;
530
539
  },
540
+ get [SURFACE_THEME]() {
541
+ return theme;
542
+ },
543
+ set [SURFACE_THEME](newVal) {
544
+ theme = newVal;
545
+ },
531
546
  get yes() {
532
547
  return yes;
533
548
  },
@@ -0,0 +1,122 @@
1
+ // Theme declarations for the controls ClayJS draws. tokenRules() maps a theme's tokens
2
+ // onto one semantic role in one state; partRules() applies a page's explicit part
3
+ // overrides, general to specific. Both return "prop:value" strings for hostile-css,
4
+ // and both return nothing for no theme or the exact Bevel theme.
5
+
6
+ export const themed = (theme) => !!theme && !theme.bevel;
7
+
8
+ const mix = (a, b, pct) => `color-mix(in srgb, ${a}, ${b} ${pct}%)`;
9
+ const decl = (prop, value) => (value ? [`${prop}:${value}`] : []);
10
+
11
+ // States in the order they apply. Disabled suppresses hover and pressed.
12
+ export function stateList({ hover = false, active = false, focus = false, disabled = false } = {}) {
13
+ const list = ["base"];
14
+ if (!disabled && hover) list.push("hover");
15
+ if (!disabled && active) list.push("active");
16
+ if (focus) list.push("focus");
17
+ if (disabled) list.push("disabled");
18
+ return list;
19
+ }
20
+
21
+ // Hover and press on neutral controls: a page's own background colour when it gave one
22
+ // that differs from the surface, else the surface tinted toward the text (or the
23
+ // control's own colour when the theme has no text).
24
+ function neutral(t, pct) {
25
+ if (t.background && t.background !== t.surface) return pct > 8 ? mix(t.background, t.text || "currentColor", 6) : t.background;
26
+ if (t.surface) return mix(t.surface, t.text || "currentColor", pct);
27
+ return t.background;
28
+ }
29
+
30
+ function button(t, variant, state) {
31
+ const r = [];
32
+ const fill = { primary: ["accent", "accentText"], danger: ["danger", "dangerText"] }[variant];
33
+ if (state === "base") r.push(...decl("font-family", t.font), ...decl("border-radius", t.radius));
34
+ if (state === "base" && t.buttonHeight) r.push("height:auto", "block-size:auto", `min-height:${t.buttonHeight}`);
35
+ if (fill) {
36
+ const bg = t[fill[0]];
37
+ const fg = t[fill[1]];
38
+ if (state === "base") r.push(...decl("background", bg), ...decl("border-color", bg), ...decl("color", fg));
39
+ if (state === "hover" && bg && fg) r.push(`background:${mix(bg, fg, 12)}`);
40
+ if (state === "active" && bg) r.push(`border-color:${bg}`, ...(fg ? [`background:${mix(bg, fg, 20)}`] : []));
41
+ if (state === "disabled" && bg) r.push("opacity:.55");
42
+ } else if (variant === "quiet") {
43
+ if (state === "base") r.push(...decl("color", t.text));
44
+ if (state === "hover") r.push(...decl("background", neutral(t, 7)));
45
+ if (state === "active") r.push(...decl("background", neutral(t, 12)));
46
+ if (state === "disabled") r.push(...decl("color", t.mutedText));
47
+ } else {
48
+ if (state === "base") r.push(...decl("background", t.surface), ...decl("color", t.text), ...decl("border-color", t.border));
49
+ if (state === "hover") r.push(...decl("background", neutral(t, 7)));
50
+ if (state === "active") r.push(...decl("background", neutral(t, 12)), ...decl("border-color", t.border));
51
+ if (state === "disabled") r.push(...decl("color", t.mutedText));
52
+ }
53
+ if (state === "focus" && t.accent) r.push(`outline:2px solid ${t.accent}`);
54
+ return r;
55
+ }
56
+
57
+ const ROLES = {
58
+ input: (t, s) =>
59
+ s === "base" ? [...decl("font-family", t.font), ...decl("color", t.text), ...decl("background", t.surface), ...decl("border-color", t.border), ...decl("border-radius", t.radius)]
60
+ : s === "hover" ? decl("border-color", t.mutedText)
61
+ : s === "focus" && t.accent ? [`outline:2px solid ${t.accent}`, `border-color:${t.accent}`]
62
+ : [],
63
+ surface: (t, s) =>
64
+ s === "base" ? [...decl("background", t.surface), ...decl("color", t.text), ...decl("border-color", t.border), ...decl("box-shadow", t.shadow), ...decl("border-radius", t.radius), ...decl("font-family", t.font)] : [],
65
+ text: (t, s) => (s === "base" ? [...decl("color", t.text), ...decl("font-family", t.font)] : []),
66
+ mutedText: (t, s) => (s === "base" ? [...decl("color", t.mutedText), ...decl("font-family", t.font)] : []),
67
+ heading: (t, s) => (s === "base" ? [...decl("font-family", t.headingFont || t.font), ...decl("color", t.text)] : []),
68
+ code: (t, s) => (s === "base" ? decl("color", t.text) : []),
69
+ well: (t, s) =>
70
+ s === "base" ? [...decl("background", t.background), ...(t.border ? [`border:1px solid ${t.border}`] : []), ...decl("border-radius", t.radius)] : [],
71
+ overlay: (t, s) => (s === "base" ? decl("background", t.overlay) : []),
72
+ close: (t, s) =>
73
+ s === "base" ? [...decl("background", t.surface), ...decl("color", t.mutedText), ...decl("border-color", t.border)]
74
+ : s === "hover" ? decl("background", neutral(t, 7))
75
+ : s === "focus" && t.accent ? [`outline:2px solid ${t.accent}`] : [],
76
+ };
77
+
78
+ /** Token declarations for `role` ("button.primary", "input", "surface", ...) in `state`. */
79
+ export function tokenRules(theme, role, state = "base") {
80
+ if (!themed(theme)) return [];
81
+ if (role.startsWith("button.")) return button(theme.tokens, role.slice(7), state);
82
+ return ROLES[role] ? ROLES[role](theme.tokens, state) : [];
83
+ }
84
+
85
+ /** Token declarations for every applicable state, in order. */
86
+ export function tokenRulesFor(theme, role, active) {
87
+ return stateList(active).flatMap((s) => tokenRules(theme, role, s));
88
+ }
89
+
90
+ /** A page's explicit part declarations: each name general to specific, base then states. */
91
+ export function partRules(theme, names, active) {
92
+ if (!themed(theme) || !names?.length) return [];
93
+ const states = stateList(active);
94
+ const r = [];
95
+ for (const name of names) {
96
+ const part = theme.parts[name];
97
+ if (!part) continue;
98
+ for (const s of states) for (const [prop, value] of Object.entries(part[s] || {})) r.push(`${prop}:${value}`);
99
+ }
100
+ return r;
101
+ }
102
+
103
+ /** Mark a themed element with its part names, so the contract can be inspected. */
104
+ export function markParts(el, theme, names) {
105
+ if (themed(theme) && names?.length) el.setAttribute("data-clay-part", names.join(" "));
106
+ }
107
+
108
+ // A floor goes on after the theme: first the plain floor, then the larger of the floor
109
+ // and what the theme asked for. When the theme's value cannot go inside max() (a keyword
110
+ // such as auto), the browser drops that second declaration and the plain floor stands.
111
+ export function floorRules(rules, prop, px) {
112
+ if (!px) return [];
113
+ const last = [...rules].reverse().find((r) => r.startsWith(`${prop}:`));
114
+ const value = last ? last.slice(prop.length + 1).trim() : "";
115
+ const out = [`${prop}:${px}px`];
116
+ if (value && value !== `${px}px`) out.push(`${prop}:max(${px}px, ${value})`);
117
+ return out;
118
+ }
119
+
120
+ export function heightFloor(rules, narrow) {
121
+ return narrow ? floorRules(rules, "min-height", 44) : [];
122
+ }