@teamofsilicons/silicon-ui 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.
Files changed (112) hide show
  1. package/README.md +21 -2
  2. package/check.mjs +245 -0
  3. package/components/activity-heatmap/activity-heatmap.module.css +4 -4
  4. package/components/alert/alert.module.css +1 -1
  5. package/components/announcement-bar/announcement-bar.module.css +6 -6
  6. package/components/avatar/avatar.d.ts +2 -2
  7. package/components/avatar/avatar.js +1 -1
  8. package/components/avatar/avatar.module.css +5 -2
  9. package/components/avatar-group/avatar-group.module.css +4 -4
  10. package/components/badge/badge.module.css +4 -4
  11. package/components/bar-chart/bar-chart.module.css +4 -4
  12. package/components/billing-toggle/billing-toggle.module.css +10 -10
  13. package/components/bottom-sheet/bottom-sheet.module.css +4 -5
  14. package/components/breadcrumb/breadcrumb.d.ts +8 -2
  15. package/components/breadcrumb/breadcrumb.js +21 -9
  16. package/components/breadcrumb/breadcrumb.module.css +7 -3
  17. package/components/brush-chart/brush-chart.module.css +9 -9
  18. package/components/button/button.d.ts +7 -2
  19. package/components/button/button.js +3 -1
  20. package/components/button/button.module.css +10 -1
  21. package/components/button-group/button-group.module.css +6 -6
  22. package/components/calendar/calendar.module.css +7 -7
  23. package/components/card/card.module.css +2 -2
  24. package/components/card-stack/card-stack.module.css +2 -3
  25. package/components/carousel/carousel.module.css +2 -2
  26. package/components/chat-thread/chat-thread.module.css +41 -41
  27. package/components/checkbox/checkbox.module.css +1 -1
  28. package/components/chip-group/chip-group.module.css +2 -2
  29. package/components/code-block/code-block.module.css +7 -14
  30. package/components/color-picker/color-picker.module.css +20 -21
  31. package/components/combobox/combobox.module.css +2 -2
  32. package/components/command-palette/command-palette.module.css +18 -18
  33. package/components/comment-thread/comment-thread.module.css +35 -35
  34. package/components/confirm-morph/confirm-morph.module.css +9 -9
  35. package/components/context-menu/context-menu.module.css +3 -3
  36. package/components/countdown/countdown.module.css +4 -4
  37. package/components/date-picker/date-picker.module.css +6 -6
  38. package/components/date-range-picker/date-range-picker.module.css +21 -21
  39. package/components/dialog/dialog.module.css +2 -2
  40. package/components/donut-chart/donut-chart.module.css +4 -4
  41. package/components/drawer/drawer.module.css +5 -5
  42. package/components/dropdown-menu/dropdown-menu.d.ts +71 -1
  43. package/components/dropdown-menu/dropdown-menu.js +100 -6
  44. package/components/dropdown-menu/dropdown-menu.module.css +15 -5
  45. package/components/elastic-slider/elastic-slider.module.css +4 -4
  46. package/components/expandable-card/expandable-card.module.css +1 -1
  47. package/components/expanding-button-group/expanding-button-group.module.css +1 -1
  48. package/components/expanding-search/expanding-search.module.css +9 -9
  49. package/components/file-dropzone/file-dropzone.module.css +21 -21
  50. package/components/file-upload/file-upload.module.css +7 -7
  51. package/components/filter-toolbar/filter-toolbar.module.css +9 -9
  52. package/components/floating-button-group/floating-button-group.module.css +6 -6
  53. package/components/gauge/gauge.module.css +2 -2
  54. package/components/hover-card/hover-card.module.css +2 -2
  55. package/components/image-compare/image-compare.module.css +4 -4
  56. package/components/inline-edit/inline-edit.module.css +12 -12
  57. package/components/json-viewer/json-viewer.module.css +12 -12
  58. package/components/line-chart/line-chart.module.css +8 -8
  59. package/components/mention-input/mention-input.module.css +8 -8
  60. package/components/metric-card/metric-card.module.css +1 -1
  61. package/components/money-input/money-input.module.css +9 -9
  62. package/components/morph-select/morph-select.module.css +13 -13
  63. package/components/multi-select/multi-select.module.css +6 -6
  64. package/components/nav-list/nav-list.d.ts +64 -0
  65. package/components/nav-list/nav-list.js +103 -0
  66. package/components/nav-list/nav-list.module.css +25 -0
  67. package/components/notification-center/notification-center.module.css +27 -27
  68. package/components/number-field/number-field.module.css +2 -2
  69. package/components/otp-input/otp-input.module.css +4 -4
  70. package/components/password-field/password-field.module.css +1 -1
  71. package/components/password-strength/password-strength.module.css +7 -7
  72. package/components/phone-input/phone-input.module.css +19 -19
  73. package/components/popover/popover.module.css +2 -2
  74. package/components/progress/progress.module.css +1 -1
  75. package/components/radio-cards/radio-cards.module.css +8 -8
  76. package/components/radio-group/radio-group.module.css +1 -1
  77. package/components/resizable-panels/resizable-panels.module.css +5 -5
  78. package/components/rich-text-editor/rich-text-editor.module.css +12 -12
  79. package/components/ridgeline/ridgeline.module.css +7 -7
  80. package/components/scroll-area/scroll-area.module.css +8 -8
  81. package/components/segmented-control/segmented-control.d.ts +3 -1
  82. package/components/segmented-control/segmented-control.js +2 -2
  83. package/components/segmented-control/segmented-control.module.css +7 -4
  84. package/components/select/select.module.css +5 -5
  85. package/components/shortcut-recorder/shortcut-recorder.module.css +15 -15
  86. package/components/signature-pad/signature-pad.module.css +11 -11
  87. package/components/slider/slider.module.css +4 -4
  88. package/components/slope-chart/slope-chart.module.css +7 -7
  89. package/components/sortable-data-table/sortable-data-table.module.css +4 -4
  90. package/components/split-button/split-button.module.css +5 -5
  91. package/components/stepper/stepper.module.css +3 -3
  92. package/components/streamgraph/streamgraph.module.css +7 -7
  93. package/components/swipe-actions/swipe-actions.module.css +5 -5
  94. package/components/switch/switch.module.css +1 -1
  95. package/components/tabs/tabs.d.ts +9 -2
  96. package/components/tabs/tabs.js +4 -2
  97. package/components/tabs/tabs.module.css +7 -2
  98. package/components/tag-input/tag-input.module.css +4 -4
  99. package/components/time-picker/time-picker.module.css +3 -3
  100. package/components/timeline/timeline.module.css +3 -3
  101. package/components/toast/toast.module.css +5 -5
  102. package/components/toast-stack/toast-stack.module.css +5 -5
  103. package/components/tooltip/tooltip.module.css +1 -1
  104. package/components/tree-view/tree-view.module.css +7 -7
  105. package/components/treemap/treemap.module.css +13 -13
  106. package/components/usage-meter/usage-meter.module.css +5 -5
  107. package/components/user-menu/user-menu.module.css +22 -22
  108. package/components/waffle-chart/waffle-chart.module.css +7 -7
  109. package/components.json +416 -1
  110. package/eslint.js +85 -0
  111. package/package.json +12 -2
  112. package/styles.css +44 -4
package/README.md CHANGED
@@ -40,8 +40,9 @@ components you use. Theme, accent and corners are set on `<html>`:
40
40
  `data-theme` is `light` or `dark`. `data-accent` is `neutral`, `violet`, `blue`, `green`, `amber`, `orange`,
41
41
  `coral` or `rose`. `data-corners` is `squircle` or `round`.
42
42
 
43
- Fonts: the tokens use `--font-geist` and `--font-inter` when set (for example by `next/font`), and fall back to
44
- Geist Variable, Inter Variable, then sans-serif.
43
+ Fonts: the tokens use `--font-geist` and `--font-hanken-grotesk` when set (for example by `next/font`), and fall back
44
+ to Geist Variable, Hanken Grotesk Variable (the names `@fontsource-variable/geist` and
45
+ `@fontsource-variable/hanken-grotesk` register), then sans-serif. Load the fonts in the app.
45
46
 
46
47
  ## Links and images
47
48
 
@@ -59,6 +60,24 @@ import { SiliconProvider } from "@teamofsilicons/silicon-ui/provider";
59
60
 
60
61
  `@teamofsilicons/silicon-ui/components.json` lists every component, its import path and its exports.
61
62
 
63
+
64
+ ## Guardrails
65
+
66
+ Two checks keep an app on the system, so screens stay consistent however they are built (including by AI):
67
+
68
+ ```js
69
+ // eslint.config.mjs: raw <button>, <input>, <select>, <textarea>, <dialog> and borrowed library CSS are errors
70
+ import siliconUi from "@teamofsilicons/silicon-ui/eslint";
71
+ export default [...yourConfig, ...siliconUi()];
72
+ ```
73
+
74
+ ```sh
75
+ npx silicon-ui-check # raw spacing, radius, colour, font size or z-index in CSS modules fails
76
+ npx silicon-ui-check --update-baseline # tolerate today's legacy values; only new drift fails
77
+ ```
78
+
79
+ Full guide, including the Claude Code hook: [docs/guardrails.md](https://github.com/teamofsilicons/silicon-ui/blob/main/docs/guardrails.md).
80
+
62
81
  ## Licence
63
82
 
64
83
  MIT. Based on [Arc](https://github.com/kuratlielia/arc-library) by Elia Kuratli.
package/check.mjs ADDED
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * silicon-ui-check: keeps an app's CSS on Silicon UI's tokens.
4
+ *
5
+ * Fails a CSS file that sets spacing, a corner radius, a colour, a font size or a stacking layer with a raw value
6
+ * instead of a token:
7
+ *
8
+ * spacing padding, margin, gap, inset, top/right/bottom/left, scroll-margin/padding → var(--space-*)
9
+ * radius border-radius and its corners → var(--radius-*)
10
+ * color hex, rgb(), hsl(), oklch(), oklab(), lab(), lch(), color() → var(--…)
11
+ * font-size px or rem → var(--text-*)
12
+ * z-index 10 and above (or -10 and below) → var(--layer-*)
13
+ *
14
+ * Allowed everywhere: 0, auto, inherit and other keywords, anything inside var() (including fallbacks), and
15
+ * font-relative or layout-relative units (em, %, ch, vw, vh, cq*). 1px and -1px are allowed in spacing: they are
16
+ * hairline alignments against a border, not space. z-index from -9 to 9 is a local order inside a component. Mask properties are not colours (only their alpha is used). Custom property definitions (--x: …) are not
17
+ * checked: tokens are defined that way, in the files you list under `ignore`. The visually hidden block (.srOnly,
18
+ * .visuallyHidden, .sr-only) and @keyframes are skipped.
19
+ *
20
+ * Configure it in package.json:
21
+ *
22
+ * "siliconUi": {
23
+ * "css": ["components/**\/*.module.css", "app/**\/*.module.css"],
24
+ * "ignore": ["styles/tokens.css"],
25
+ * "baseline": "silicon-ui-baseline.json"
26
+ * }
27
+ *
28
+ * Usage:
29
+ * silicon-ui-check check every configured file (fails only above the baseline)
30
+ * silicon-ui-check a.css b.css check these files
31
+ * silicon-ui-check --update-baseline record today's counts, so only new drift fails from now on
32
+ * silicon-ui-check --hook Claude Code PostToolUse hook: checks the edited file (CSS, and ESLint for
33
+ * .ts/.tsx/.js/.jsx), exit 2 with the problems so the agent fixes them
34
+ */
35
+ import { spawnSync } from "node:child_process";
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+ import { fileURLToPath } from "node:url";
39
+
40
+ const RULES = {
41
+ spacing: /^(?:padding|margin|gap|row-gap|column-gap|inset|top|right|bottom|left)(?:-[a-z-]+)?$|^scroll-(?:margin|padding)(?:-[a-z-]+)?$/,
42
+ radius: /^border(?:-[a-z]+(?:-[a-z]+)?)?-radius$/,
43
+ "font-size": /^font-size$/,
44
+ };
45
+ const LENGTH = /(?<![\w.#-])(-?(?:\d*\.)?\d+)(px|rem)\b/g;
46
+ const COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\(/i;
47
+ const HIDDEN = /\.(?:srOnly|visuallyHidden|sr-only|visually-hidden)\b/;
48
+ const FIX = {
49
+ spacing: "use a spacing token: var(--space-hair|tight|related|group|inset|comfort|section|block|page|stage|screen)",
50
+ radius: "use a radius token: var(--radius-detail|control|container|overlay|full)",
51
+ color: "use a colour token: var(--foreground), var(--text-secondary), var(--border), var(--accent) …",
52
+ "font-size": "use a type token: var(--text-xs|sm|base|lg|xl|2xl|3xl|4xl|5xl)",
53
+ layer: "use a layer token: var(--layer-raised|sticky|overlay|dialog|popover|dropdown|toast|tooltip); -9 to 9 is fine for order inside a component",
54
+ };
55
+
56
+ /** Removes every var(...) (with nested parentheses), so tokens and their fallbacks are never flagged. */
57
+ function withoutVars(value) {
58
+ let out = "";
59
+ for (let i = 0; i < value.length; i++) {
60
+ if (value.startsWith("var(", i)) {
61
+ let depth = 0;
62
+ for (; i < value.length; i++) {
63
+ if (value[i] === "(") depth++;
64
+ else if (value[i] === ")" && --depth === 0) break;
65
+ }
66
+ out += "var()";
67
+ } else out += value[i];
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** Declarations with their line, selector and whether they sit inside @keyframes. */
73
+ function declarations(css) {
74
+ const source = css.replace(/\/\*[\s\S]*?\*\//g, match => match.replace(/[^\n]/g, " "));
75
+ const found = [];
76
+ const stack = [];
77
+ let start = 0;
78
+ const lineAt = index => source.slice(0, index).split("\n").length;
79
+ for (let i = 0; i < source.length; i++) {
80
+ const char = source[i];
81
+ if (char === "{") {
82
+ stack.push(source.slice(start, i).trim());
83
+ start = i + 1;
84
+ } else if (char === "}" || char === ";") {
85
+ const text = source.slice(start, i);
86
+ const colon = text.indexOf(":");
87
+ if (colon > 0 && stack.length && !/[{}]/.test(text)) {
88
+ const property = text.slice(0, colon).trim().toLowerCase();
89
+ const value = text.slice(colon + 1).trim();
90
+ const offset = start + text.length - text.trimStart().length;
91
+ if (/^[a-z-]+$/.test(property)) {
92
+ found.push({ property, value, line: lineAt(offset), selector: stack[stack.length - 1], keyframes: stack.some(p => /^@(-\w+-)?keyframes\b/.test(p)) });
93
+ }
94
+ }
95
+ if (char === "}") stack.pop();
96
+ start = i + 1;
97
+ }
98
+ }
99
+ return found;
100
+ }
101
+
102
+ export function checkCss(css) {
103
+ const problems = [];
104
+ for (const { property, value, line, selector, keyframes } of declarations(css)) {
105
+ if (keyframes || property.startsWith("--") || HIDDEN.test(selector)) continue;
106
+ const bare = withoutVars(value);
107
+ for (const [rule, pattern] of Object.entries(RULES)) {
108
+ if (!pattern.test(property)) continue;
109
+ const hairline = match => rule === "spacing" && match[2] === "px" && Math.abs(Number(match[1])) === 1;
110
+ const raw = [...bare.matchAll(LENGTH)].filter(match => Number(match[1]) !== 0 && !hairline(match)).map(match => match[0]);
111
+ if (raw.length) problems.push({ rule, line, property, value, raw: raw.join(", ") });
112
+ }
113
+ if (property === "z-index") {
114
+ const raw = [...bare.matchAll(/(?<![\w.-])-?\d+(?![\w.])/g)].map(match => match[0]).filter(number => Math.abs(Number(number)) >= 10);
115
+ if (raw.length) problems.push({ rule: "layer", line, property, value, raw: raw.join(", ") });
116
+ }
117
+ const color = /^(?:-webkit-)?mask/.test(property) ? null : bare.match(COLOR);
118
+ if (color) problems.push({ rule: "color", line, property, value, raw: color[0] });
119
+ }
120
+ return problems;
121
+ }
122
+
123
+ /* ---------------------------------------------------------------- files and config ------------------------------------------------------------- */
124
+
125
+ function globToRegExp(glob) {
126
+ let re = "";
127
+ for (let i = 0; i < glob.length; i++) {
128
+ const c = glob[i];
129
+ if (c === "*") {
130
+ if (glob[i + 1] === "*") { re += glob[i + 2] === "/" ? "(?:.*/)?" : ".*"; i += glob[i + 2] === "/" ? 2 : 1; }
131
+ else re += "[^/]*";
132
+ } else if (c === "?") re += "[^/]";
133
+ else if (c === "{") { const end = glob.indexOf("}", i); re += `(?:${glob.slice(i + 1, end).split(",").map(s => s.replace(/[.+^$()|[\]\\]/g, "\\$&")).join("|")})`; i = end; }
134
+ else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
135
+ }
136
+ return new RegExp(`^${re}$`);
137
+ }
138
+ const matches = (file, globs) => globs.some(glob => globToRegExp(glob).test(file));
139
+
140
+ function walk(dir, root) {
141
+ const out = [];
142
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
143
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
144
+ const full = path.join(dir, entry.name);
145
+ if (entry.isDirectory()) out.push(...walk(full, root));
146
+ else out.push(path.relative(root, full).split(path.sep).join("/"));
147
+ }
148
+ return out;
149
+ }
150
+
151
+ function findProject(from) {
152
+ for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
153
+ const manifest = path.join(dir, "package.json");
154
+ if (fs.existsSync(manifest)) {
155
+ const config = JSON.parse(fs.readFileSync(manifest, "utf8")).siliconUi;
156
+ if (config) return { root: dir, config: { css: ["**/*.module.css"], ignore: [], baseline: "silicon-ui-baseline.json", ...config } };
157
+ }
158
+ if (path.dirname(dir) === dir) return { root: path.resolve(from), config: { css: ["**/*.module.css"], ignore: [], baseline: "silicon-ui-baseline.json" } };
159
+ }
160
+ }
161
+
162
+ const count = problems => problems.reduce((totals, { rule }) => ({ ...totals, [rule]: (totals[rule] ?? 0) + 1 }), {});
163
+ const readBaseline = file => (fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")).files ?? {} : {});
164
+
165
+ /** Problems in a file beyond what the baseline allows for it (all of them for a file the baseline does not list). */
166
+ function overBaseline(problems, allowed = {}) {
167
+ const totals = count(problems);
168
+ return Object.keys(totals).filter(rule => totals[rule] > (allowed[rule] ?? 0)).flatMap(rule => problems.filter(problem => problem.rule === rule));
169
+ }
170
+
171
+ function report(file, problems, allowed = {}) {
172
+ const lines = [`${file}`];
173
+ for (const { rule, line, property, raw } of [...problems].sort((x, y) => x.line - y.line)) lines.push(` ${line} ${rule}: ${property} uses ${raw}; ${FIX[rule]}`);
174
+ const counted = count(problems);
175
+ const listed = Object.keys(counted).filter(rule => allowed[rule] && counted[rule] > allowed[rule]).map(rule => `${rule} ${counted[rule]}, baseline ${allowed[rule]}`);
176
+ if (listed.length) lines.push(` (over the baseline: ${listed.join("; ")}. Older raw values are listed too; fix at least the new ones)`);
177
+ return lines.join("\n");
178
+ }
179
+
180
+ /* ---------------------------------------------------------------- modes ------------------------------------------------------------------------ */
181
+
182
+ function runCli(args) {
183
+ const { root, config } = findProject(process.cwd());
184
+ const update = args.includes("--update-baseline");
185
+ const named = args.filter(arg => !arg.startsWith("--"));
186
+ const files = (named.length ? named.map(file => path.relative(root, path.resolve(file)).split(path.sep).join("/")) : walk(root, root).filter(file => matches(file, config.css)))
187
+ .filter(file => !matches(file, config.ignore));
188
+ const baselinePath = path.join(root, config.baseline);
189
+ const baseline = readBaseline(baselinePath);
190
+
191
+ if (update) {
192
+ const next = {};
193
+ for (const file of files) { const totals = count(checkCss(fs.readFileSync(path.join(root, file), "utf8"))); if (Object.keys(totals).length) next[file] = totals; }
194
+ fs.writeFileSync(baselinePath, `${JSON.stringify({ note: "Raw values that predate the Silicon UI checks. Counts may only go down: fix a file, then run silicon-ui-check --update-baseline.", files: next }, null, 2)}\n`);
195
+ const total = Object.values(next).reduce((sum, totals) => sum + Object.values(totals).reduce((a, b) => a + b, 0), 0);
196
+ console.log(`silicon-ui-check: baseline written to ${config.baseline}: ${total} raw values in ${Object.keys(next).length} files.`);
197
+ return 0;
198
+ }
199
+
200
+ let failed = 0, legacy = 0;
201
+ for (const file of files) {
202
+ const problems = checkCss(fs.readFileSync(path.join(root, file), "utf8"));
203
+ const over = overBaseline(problems, baseline[file]);
204
+ legacy += problems.length - over.length;
205
+ if (over.length) { failed++; console.error(report(file, problems.length > over.length ? problems : over, baseline[file])); }
206
+ }
207
+ if (failed) {
208
+ console.error(`\nsilicon-ui-check: ${failed} file${failed === 1 ? " uses" : "s use"} raw values where Silicon UI has tokens.`);
209
+ return 1;
210
+ }
211
+ console.log(`silicon-ui-check: ${files.length} files on tokens${legacy ? ` (${legacy} older raw values tolerated by the baseline; it only goes down)` : ""}.`);
212
+ return 0;
213
+ }
214
+
215
+ function runHook() {
216
+ let input = {};
217
+ try { input = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); } catch { return 0; }
218
+ const target = input.tool_input?.file_path ?? input.tool_input?.path;
219
+ if (!target || !fs.existsSync(target)) return 0;
220
+ const { root, config } = findProject(path.dirname(target));
221
+ const file = path.relative(root, path.resolve(target)).split(path.sep).join("/");
222
+ const messages = [];
223
+
224
+ if (/\.css$/.test(file) && matches(file, config.css) && !matches(file, config.ignore)) {
225
+ const problems = checkCss(fs.readFileSync(target, "utf8"));
226
+ const allowed = readBaseline(path.join(root, config.baseline))[file];
227
+ if (overBaseline(problems, allowed).length) messages.push(report(file, problems, allowed));
228
+ }
229
+ if (/\.(?:[cm]?[jt]sx?)$/.test(file)) {
230
+ const eslint = path.join(root, "node_modules", ".bin", "eslint");
231
+ if (fs.existsSync(eslint)) {
232
+ const result = spawnSync(eslint, ["--no-warn-ignored", "--max-warnings=0", file], { cwd: root, encoding: "utf8" });
233
+ if (result.status) messages.push((result.stdout || result.stderr).trim());
234
+ }
235
+ }
236
+ if (!messages.length) return 0;
237
+ console.error(`Silicon UI check failed for ${file}. Fix these before moving on (docs: https://github.com/teamofsilicons/silicon-ui/tree/main/docs):\n\n${messages.join("\n\n")}`);
238
+ return 2;
239
+ }
240
+
241
+ // Run as a command (directly or through node_modules/.bin), not when imported.
242
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
243
+ const args = process.argv.slice(2);
244
+ process.exitCode = args.includes("--hook") ? runHook() : runCli(args);
245
+ }
@@ -13,13 +13,13 @@
13
13
  .rise { position: relative; display: inline-flex; }
14
14
 
15
15
  /* The scroller starts at its right edge on narrow screens (the most recent weeks) without script, through its writing direction. */
16
- .scroller { direction: rtl; overflow-x: auto; overflow-y: hidden; margin: -6px; padding: 6px; overscroll-behavior-x: contain; scrollbar-color: var(--border-strong) transparent; scrollbar-width: thin; }
16
+ .scroller { direction: rtl; overflow-x: auto; overflow-y: hidden; margin: calc(var(--space-related) * -1); padding: var(--space-related); overscroll-behavior-x: contain; scrollbar-color: var(--border-strong) transparent; scrollbar-width: thin; }
17
17
  .canvas { --gap: 3px; --label: 30px; --cell: clamp(9px, calc((100cqi - var(--label) - var(--gap) * 53) / 53), 15px); --step: calc(var(--cell) + var(--gap)); direction: ltr; display: grid; width: max-content; grid-template-columns: var(--label) auto; grid-template-rows: auto auto; column-gap: var(--gap); row-gap: var(--space-related); }
18
18
  .months { position: relative; grid-row: 1; grid-column: 2; height: 1rem; color: var(--text-muted); font-size: var(--text-xs); line-height: 1rem; }
19
19
  /* Month labels glide to their new week when a range starts on a different weekday. */
20
20
  .month { position: absolute; top: 0; left: 0; white-space: nowrap; transform: translateX(calc(var(--col) * var(--step))); transition: transform var(--duration-spring) var(--ease-spring); }
21
21
  /* Weekday labels stay pinned while a narrow grid scrolls under them. Set --heatmap-surface when the heatmap sits on another surface. */
22
- .weekdays { position: sticky; z-index: 1; left: -6px; display: grid; grid-row: 1 / 3; grid-column: 1; grid-template-rows: repeat(7, var(--cell)); align-content: end; row-gap: var(--gap); margin: 0 calc(var(--gap) * -1) 0 -6px; padding: 0 var(--gap) 0 6px; background: var(--heatmap-surface, var(--surface)); color: var(--text-muted); font-size: var(--text-xs); }
22
+ .weekdays { position: sticky; z-index: 1; left: calc(var(--space-related) * -1); display: grid; grid-row: 1 / 3; grid-column: 1; grid-template-rows: repeat(7, var(--cell)); align-content: end; row-gap: var(--gap); margin: 0 calc(var(--gap) * -1) 0 calc(var(--space-related) * -1); padding: 0 var(--gap) 0 var(--space-related); background: var(--heatmap-surface, var(--surface)); color: var(--text-muted); font-size: var(--text-xs); }
23
23
  .weekday { display: flex; height: var(--cell); align-items: center; white-space: nowrap; line-height: 1; }
24
24
  .plot { position: relative; grid-row: 2; grid-column: 2; }
25
25
  .grid { display: grid; gap: var(--gap); outline: none; }
@@ -42,7 +42,7 @@
42
42
  @media (hover: hover) and (pointer: fine) { .cell:not([data-empty]):hover { outline-color: color-mix(in oklab, var(--foreground) 45%, transparent); } }
43
43
 
44
44
  /* The selection ring glides between days, and follows its day when the range changes. */
45
- .ring { position: absolute; top: -3px; left: -3px; width: calc(var(--cell) + 6px); height: calc(var(--cell) + 6px); border: 1.5px solid var(--foreground); border-radius: calc(var(--cell) * .28 + 3px); opacity: 0; pointer-events: none; transform: translate(calc(var(--col) * var(--step)), calc(var(--row) * var(--step))); transition: transform var(--duration-spring) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }
45
+ .ring { position: absolute; top: calc(var(--space-tight) * -1); left: calc(var(--space-tight) * -1); width: calc(var(--cell) + 6px); height: calc(var(--cell) + 6px); border: 1.5px solid var(--foreground); border-radius: calc(var(--cell) * .28 + var(--space-tight)); opacity: 0; pointer-events: none; transform: translate(calc(var(--col) * var(--step)), calc(var(--row) * var(--step))); transition: transform var(--duration-spring) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }
46
46
  .ring[data-shown] { opacity: 1; }
47
47
 
48
48
  .legend { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--space-group); color: var(--text-muted); font-size: var(--text-xs); }
@@ -65,7 +65,7 @@
65
65
 
66
66
  /* One bubble for the whole grid. The outer layer glides between cells; the bubble fades and scales from its tail. */
67
67
  .tip { position: absolute; z-index: 5; top: 0; left: 0; width: 0; height: 0; pointer-events: none; }
68
- .bubble { position: absolute; bottom: 8px; left: 0; display: block; padding: 7px 12px 8px; border: 1px solid color-mix(in oklab, var(--background) 14%, var(--foreground)); border-radius: var(--radius-control); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); transform-origin: 50% 100%; will-change: transform, opacity; }
68
+ .bubble { position: absolute; bottom: var(--space-related); left: 0; display: block; padding: var(--space-related) var(--space-group) var(--space-related); border: 1px solid color-mix(in oklab, var(--background) 14%, var(--foreground)); border-radius: var(--radius-control); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); transform-origin: 50% 100%; will-change: transform, opacity; }
69
69
  .tipBody { position: relative; display: block; overflow: clip; overflow-clip-margin: 8px; }
70
70
  .tipMeasure { position: absolute; top: 0; left: 0; display: grid; width: max-content; visibility: hidden; }
71
71
  .tipLines { display: grid; width: max-content; }
@@ -9,7 +9,7 @@
9
9
  .line { display: block; }
10
10
  .description { position: relative; margin: var(--space-tight) 0 0; color: var(--text-secondary); font-size: var(--text-xs); }
11
11
  .info .icon { color: var(--accent); } .success .icon { color: var(--success); } .warning .icon { color: var(--warning); } .danger .icon { color: var(--danger); }
12
- .dismiss { display: grid; width: 28px; height: 28px; flex: 0 0 auto; place-items: center; margin: -5px -6px -5px 0; border: 0; border-radius: var(--radius-full); corner-shape: round; background: transparent; color: var(--text-muted); cursor: pointer; transition: transform var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
12
+ .dismiss { display: grid; width: 28px; height: 28px; flex: 0 0 auto; place-items: center; margin: calc(var(--space-tight) * -1) calc(var(--space-related) * -1) calc(var(--space-tight) * -1) 0; border: 0; border-radius: var(--radius-full); corner-shape: round; background: transparent; color: var(--text-muted); cursor: pointer; transition: transform var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
13
13
  .dismiss:active { transform: scale(.96); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }
14
14
  @media (hover: hover) and (pointer: fine) { .dismiss:hover { background: var(--surface-muted); color: var(--foreground); } }
15
15
  @media (prefers-reduced-motion: reduce) { .dismiss { transition: none; } .dismiss:active { transform: none; } }
@@ -2,7 +2,7 @@
2
2
  .collapse { overflow: hidden; container-type: inline-size; font-family: var(--font-body); letter-spacing: var(--tracking-body); }
3
3
 
4
4
  /* The side column mirrors the controls, so the message stays centered on the page rather than in the leftover space. */
5
- .bar { --icon: 32px; --control-gap: 2px; --muted: var(--text-secondary); display: grid; grid-template-columns: calc(var(--controls, 0) * var(--icon) + max(var(--controls, 0) - 1, 0) * var(--control-gap)) minmax(0, 1fr) auto; align-items: center; gap: var(--space-group); padding: 6px 8px 6px 16px; border-bottom: 1px solid var(--border); background: var(--surface-muted); color: var(--foreground); }
5
+ .bar { --icon: 32px; --control-gap: 2px; --muted: var(--text-secondary); display: grid; grid-template-columns: calc(var(--controls, 0) * var(--icon) + max(var(--controls, 0) - 1, 0) * var(--control-gap)) minmax(0, 1fr) auto; align-items: center; gap: var(--space-group); padding: var(--space-related) var(--space-related) var(--space-related) var(--space-inset); border-bottom: 1px solid var(--border); background: var(--surface-muted); color: var(--foreground); }
6
6
  .bar[data-tone="inverted"] { --muted: color-mix(in oklab, var(--background) 66%, transparent); border-bottom-color: transparent; background: var(--foreground); color: var(--background); }
7
7
 
8
8
  .side { min-width: 0; }
@@ -10,11 +10,11 @@
10
10
 
11
11
  /* Faces overlap in place; the entering one reports its height and the viewport springs to it. */
12
12
  .face { position: absolute; top: 0; right: 0; left: 0; display: flex; min-height: 32px; align-items: center; justify-content: center; }
13
- .message { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: 2px 12px; margin: 0; padding: 6px 0; font-size: var(--text-sm); line-height: var(--leading-body); text-align: center; text-wrap: balance; }
13
+ .message { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: var(--space-hair) var(--space-group); margin: 0; padding: var(--space-related) 0; font-size: var(--text-sm); line-height: var(--leading-body); text-align: center; text-wrap: balance; }
14
14
  .text { min-width: 0; }
15
15
 
16
16
  /* The countdown is one run of text: every cell shares the line box, so digits, colons, and the day mark sit on the message baseline. */
17
- .countdown { display: inline-flex; align-items: baseline; gap: 6px; color: var(--muted); white-space: nowrap; }
17
+ .countdown { display: inline-flex; align-items: baseline; gap: var(--space-related); color: var(--muted); white-space: nowrap; }
18
18
  .countdownLabel { color: inherit; }
19
19
  .time { display: inline-flex; align-items: baseline; color: var(--foreground); font-variant-numeric: tabular-nums; font-weight: 500; line-height: inherit; }
20
20
  .time[data-pending] { opacity: 0; }
@@ -28,7 +28,7 @@
28
28
  .sep { display: inline-block; padding-inline: 1px; color: var(--muted); font-feature-settings: "case"; font-weight: 400; }
29
29
  .gap { display: inline-block; width: .4em; }
30
30
 
31
- .cta { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: none; color: inherit; cursor: pointer; font: inherit; font-weight: 500; text-decoration: none; white-space: nowrap; -webkit-tap-highlight-color: transparent; }
31
+ .cta { display: inline-flex; align-items: center; gap: var(--space-tight); padding: 0; border: 0; background: none; color: inherit; cursor: pointer; font: inherit; font-weight: 500; text-decoration: none; white-space: nowrap; -webkit-tap-highlight-color: transparent; }
32
32
  .cta svg { transition: transform var(--duration-fast) var(--ease-standard); }
33
33
 
34
34
  .controls { display: flex; align-items: center; gap: var(--control-gap); }
@@ -50,9 +50,9 @@
50
50
 
51
51
  /* Narrow bars drop the mirror column and read from the start edge. */
52
52
  @container (max-width: 560px) {
53
- .bar { grid-template-columns: 0 minmax(0, 1fr) auto; gap: 0 var(--space-related); padding-left: 14px; }
53
+ .bar { grid-template-columns: 0 minmax(0, 1fr) auto; gap: 0 var(--space-related); padding-left: var(--space-inset); }
54
54
  .face { justify-content: flex-start; }
55
- .message { justify-content: flex-start; column-gap: 10px; text-align: left; }
55
+ .message { justify-content: flex-start; column-gap: var(--space-group); text-align: left; }
56
56
  }
57
57
 
58
58
  @media (prefers-reduced-motion: reduce) {
@@ -1,8 +1,8 @@
1
1
  import { type HTMLAttributes } from "react";
2
2
  export interface AvatarProps extends HTMLAttributes<HTMLSpanElement> {
3
3
  name: string;
4
- src?: string;
5
- size?: "sm" | "md" | "lg" | "xl";
4
+ src?: string; /** `xs` (20px) is for dense lists such as space icons in a tree; `sm` 28, `md` 36, `lg` 48, `xl` 88. */
5
+ size?: "xs" | "sm" | "md" | "lg" | "xl";
6
6
  status?: "online" | "offline";
7
7
  }
8
8
  export declare function Avatar({ name, src, size, status, className, ...props }: AvatarProps): import("react").JSX.Element;
@@ -14,5 +14,5 @@ export function Avatar({ name, src, size = "md", status, className, ...props })
14
14
  useLayoutEffect(() => { const node = image.current; if (node && !node.complete)
15
15
  node.dataset.loading = ""; }, [src]);
16
16
  const showImage = src && failedSrc !== src;
17
- return _jsxs("span", { ...props, className: [styles.avatar, styles[size], className].filter(Boolean).join(" "), role: "img", "aria-label": `${name}${status ? `, ${status}` : ""}`, children: [showImage ? _jsx(SiliconImage, { ref: image, src: src, alt: "", fill: true, sizes: size === "xl" ? "88px" : size === "lg" ? "48px" : size === "md" ? "36px" : "28px", onLoad: event => { delete event.currentTarget.dataset.loading; }, onError: () => setFailedSrc(src) }, src) : _jsx("span", { className: src ? styles.fallback : undefined, "aria-hidden": "true", children: initials }), _jsx(AnimatePresence, { initial: false, children: status && _jsx(motion.i, { className: [styles.status, styles[status]].join(" "), "aria-hidden": "true", initial: { opacity: 0, scale: .6 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: .6, transition: { duration: reduceMotion ? 0 : motionTokens.duration.fast } }, transition: reduceMotion ? { duration: 0 } : motionTokens.spring.snappy }, status) })] });
17
+ return _jsxs("span", { ...props, className: [styles.avatar, styles[size], className].filter(Boolean).join(" "), role: "img", "aria-label": `${name}${status ? `, ${status}` : ""}`, children: [showImage ? _jsx(SiliconImage, { ref: image, src: src, alt: "", fill: true, sizes: size === "xl" ? "88px" : size === "lg" ? "48px" : size === "md" ? "36px" : size === "sm" ? "28px" : "20px", onLoad: event => { delete event.currentTarget.dataset.loading; }, onError: () => setFailedSrc(src) }, src) : _jsx("span", { className: src ? styles.fallback : undefined, "aria-hidden": "true", children: initials }), _jsx(AnimatePresence, { initial: false, children: status && _jsx(motion.i, { className: [styles.status, styles[status]].join(" "), "aria-hidden": "true", initial: { opacity: 0, scale: .6 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: .6, transition: { duration: reduceMotion ? 0 : motionTokens.duration.fast } }, transition: reduceMotion ? { duration: 0 } : motionTokens.spring.snappy }, status) })] });
18
18
  }
@@ -28,7 +28,9 @@
28
28
 
29
29
  .fallback { animation: avatar-fallback-in var(--duration-standard) var(--ease-enter) both; }
30
30
 
31
- .sm { width: 28px; height: 28px; font-size: 10px; }
31
+ /* Initials below the smallest type step, so two letters fit 20px. */
32
+ .xs { width: 20px; height: 20px; font-size: calc(var(--text-xs) * .8); }
33
+ .sm { width: 28px; height: 28px; font-size: var(--text-xs); }
32
34
  .md { width: 36px; height: 36px; }
33
35
  .lg { width: 48px; height: 48px; font-size: var(--text-sm); }
34
36
  .xl { width: 88px; height: 88px; font-size: var(--text-xl); }
@@ -45,9 +47,10 @@
45
47
  box-shadow: 0 0 0 1px color-mix(in oklab, var(--border) 70%, transparent);
46
48
  }
47
49
 
50
+ .xs .status { width: 6px; height: 6px; border-width: 1px; }
48
51
  .sm .status { width: 8px; height: 8px; border-width: 1.5px; }
49
52
  .lg .status { width: 12px; height: 12px; }
50
- .xl .status { width: 14px; height: 14px; right: 4px; bottom: 4px; }
53
+ .xl .status { width: 14px; height: 14px; right: var(--space-tight); bottom: var(--space-tight); }
51
54
  .online { background: var(--success); }
52
55
  .offline { background: var(--text-muted); }
53
56
 
@@ -1,18 +1,18 @@
1
- .group { --avatar-size: 36px; display: inline-flex; height: var(--avatar-size); align-items: center; padding-left: 6px; }
1
+ .group { --avatar-size: 36px; display: inline-flex; height: var(--avatar-size); align-items: center; padding-left: var(--space-related); }
2
2
  .group.sm { --avatar-size: 28px; }
3
3
  .group.lg { --avatar-size: 48px; }
4
4
  /* Each slot is the avatar's visible width, so a slot that opens or closes moves the rest of the stack smoothly. */
5
5
  .slot { position: relative; display: inline-flex; flex: 0 0 auto; height: var(--avatar-size); align-items: center; }
6
6
  /* One wrapper per person carries every hover move as a single translate, so the stack never changes size. */
7
- .lift { --fan: 0px; --aside: 0px; --rise: 0px; position: relative; display: inline-flex; margin-left: -6px; border-radius: var(--avatar-radius); corner-shape: var(--avatar-corner-shape); transform: translate(calc(var(--fan) + var(--aside)), var(--rise)); transition: transform var(--duration-standard) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
7
+ .lift { --fan: 0px; --aside: 0px; --rise: 0px; position: relative; display: inline-flex; margin-left: calc(var(--space-related) * -1); border-radius: var(--avatar-radius); corner-shape: var(--avatar-corner-shape); transform: translate(calc(var(--fan) + var(--aside)), var(--rise)); transition: transform var(--duration-standard) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
8
8
  .avatar, .overflow { border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--border); transition: box-shadow var(--duration-fast) var(--ease-standard); }
9
9
  .overflow { position: relative; display: inline-grid; place-items: center; overflow: hidden; border-radius: var(--avatar-radius); corner-shape: var(--avatar-corner-shape); background: var(--surface-muted); color: var(--text-secondary); font-size: var(--text-xs); font-weight: 500; font-variant-numeric: tabular-nums; }
10
10
  .count { display: inline-block; }
11
- .overflow.sm { width: 28px; height: 28px; font-size: 10px; }
11
+ .overflow.sm { width: 28px; height: 28px; font-size: var(--text-xs); }
12
12
  .overflow.md { width: 36px; height: 36px; }
13
13
  .overflow.lg { width: 48px; height: 48px; font-size: var(--text-sm); }
14
14
  /* The name floats above the stack, outside the layout, so showing it never moves anything. */
15
- .tip { position: absolute; z-index: 2; bottom: calc(100% + 8px); left: 50%; padding: 4px 8px; border-radius: var(--radius-full); corner-shape: round; background: var(--foreground); color: var(--background); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, 3px); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }
15
+ .tip { position: absolute; z-index: 2; bottom: calc(100% + var(--space-related)); left: 50%; padding: var(--space-tight) var(--space-related); border-radius: var(--radius-full); corner-shape: round; background: var(--foreground); color: var(--background); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, 3px); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }
16
16
  /* Pointing at the stack loosens its overlap from the center; the person under the pointer lifts with a crisp ring and names themselves, and their neighbors ease aside. */
17
17
  @media (hover: hover) and (pointer: fine) {
18
18
  .group:hover .lift { --fan: calc((var(--index) - (var(--count) - 1) / 2) * 4px); }
@@ -4,7 +4,7 @@
4
4
  align-items: center;
5
5
  border: 1px solid var(--badge-border, var(--border));
6
6
  border-radius: var(--radius-full); corner-shape: round;
7
- padding: 0 10px;
7
+ padding: 0 var(--space-group);
8
8
  color: var(--badge-foreground, var(--foreground));
9
9
  background: var(--badge-background, var(--surface));
10
10
  font-size: var(--text-xs);
@@ -20,12 +20,12 @@
20
20
  @media (hover: hover) and (pointer: fine) {
21
21
  .badge:hover { border-color: color-mix(in oklab, var(--badge-color, var(--foreground)) 32%, var(--border)); background: color-mix(in oklab, var(--badge-color, var(--foreground)) 10%, var(--surface)); }
22
22
  }
23
- .sm { min-height: 22px; padding: 0 8px; font-size: 11px; }
23
+ .sm { min-height: 22px; padding: 0 var(--space-related); font-size: var(--text-xs); }
24
24
  .md { font-size: var(--text-xs); }
25
25
 
26
26
  .body { display: block; }
27
- .content { position: relative; display: inline-flex; width: max-content; align-items: center; gap: 5px; }
28
- .sm .content { gap: 4px; }
27
+ .content { position: relative; display: inline-flex; width: max-content; align-items: center; gap: var(--space-tight); }
28
+ .sm .content { gap: var(--space-tight); }
29
29
  .label, .glyph { position: relative; display: inline-flex; align-items: center; }
30
30
  .text { display: block; }
31
31
 
@@ -1,11 +1,11 @@
1
1
  .figure { display: grid; min-width: 0; gap: var(--space-comfort); margin: 0; color: var(--foreground); }
2
2
 
3
3
  /* The headline is the readout: the average at rest, the scrubbed bar while exploring. Each line keeps its height, so swaps never move the plot. */
4
- .header { display: grid; min-width: 0; gap: 2px; }
4
+ .header { display: grid; min-width: 0; gap: var(--space-hair); }
5
5
  .kind, .when { display: block; min-height: calc(1em * var(--leading-body)); font-size: var(--text-sm); line-height: var(--leading-body); }
6
6
  .kind { color: var(--text-secondary); }
7
7
  .when { color: var(--text-muted); }
8
- .value { display: flex; min-width: 0; align-items: baseline; gap: 6px; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); font-variant-numeric: tabular-nums; }
8
+ .value { display: flex; min-width: 0; align-items: baseline; gap: var(--space-related); font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); font-variant-numeric: tabular-nums; }
9
9
  .unit { color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-base); font-weight: 400; letter-spacing: var(--tracking-body); }
10
10
  .swap { position: relative; display: block; min-width: 0; }
11
11
  .swapText { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -33,12 +33,12 @@
33
33
  .chart .bar[data-active] { fill: var(--accent); }
34
34
 
35
35
  .gutter { position: relative; grid-area: 1 / 2; min-width: 0; }
36
- .tick, .meanLabel { position: absolute; top: 0; left: 10px; display: block; height: 0; white-space: nowrap; pointer-events: none; }
36
+ .tick, .meanLabel { position: absolute; top: 0; left: var(--space-group); display: block; height: 0; white-space: nowrap; pointer-events: none; }
37
37
  .tickText { display: block; transform: translateY(-50%); color: var(--text-muted); font-size: var(--text-xs); line-height: 1; font-variant-numeric: tabular-nums; }
38
38
  .meanLabel .tickText { color: var(--foreground); font-weight: 500; }
39
39
 
40
40
  .axis { position: relative; grid-area: 2 / 1; min-width: 0; overflow: hidden; }
41
- .axisLabel { position: absolute; top: 8px; left: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; }
41
+ .axisLabel { position: absolute; top: var(--space-related); left: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; }
42
42
 
43
43
  /* The scrubber covers the plot and its axes. Horizontal drags scrub, vertical swipes still scroll the page. */
44
44
  .scrubber { position: absolute; inset: 0; border-radius: calc(var(--radius-control) / 2); outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
@@ -26,14 +26,14 @@
26
26
  z-index: -1;
27
27
  border-radius: var(--radius-full); corner-shape: round;
28
28
  background: var(--surface-raised);
29
- box-shadow: 0 0 0 1px var(--border-subtle), 0 1px 2px oklch(0% 0 0 / .06), 0 2px 6px oklch(0% 0 0 / .04);
29
+ box-shadow: 0 0 0 1px var(--border-subtle), var(--shadow-thumb);
30
30
  pointer-events: none;
31
31
  }
32
32
  .thumb { top: var(--_pad); bottom: var(--_pad); left: 0; will-change: transform, width; }
33
33
  .thumbStatic { inset: 0; }
34
34
  :global(:root[data-theme="dark"]) .thumb,
35
35
  :global(:root[data-theme="dark"]) .thumbStatic {
36
- box-shadow: 0 0 0 1px var(--border), inset 0 1px 0 oklch(100% 0 0 / .05), 0 1px 2px oklch(0% 0 0 / .3);
36
+ box-shadow: 0 0 0 1px var(--border), var(--shadow-thumb);
37
37
  }
38
38
 
39
39
  .option {
@@ -42,7 +42,7 @@
42
42
  min-width: 0;
43
43
  height: var(--_h);
44
44
  align-items: center;
45
- padding: 0 14px;
45
+ padding: 0 var(--space-inset);
46
46
  border: 0;
47
47
  border-radius: var(--radius-full); corner-shape: round;
48
48
  background: transparent;
@@ -55,9 +55,9 @@
55
55
  -webkit-tap-highlight-color: transparent;
56
56
  }
57
57
  .option[data-selected] { color: var(--foreground); cursor: default; }
58
- .option:has(.badge) { padding-right: 5px; }
59
- .root[data-size="lg"] .option { padding-inline: 18px; }
60
- .root[data-size="lg"] .option:has(.badge) { padding-right: 6px; }
58
+ .option:has(.badge) { padding-right: var(--space-tight); }
59
+ .root[data-size="lg"] .option { padding-inline: var(--space-comfort); }
60
+ .root[data-size="lg"] .option:has(.badge) { padding-right: var(--space-related); }
61
61
 
62
62
  @media (hover: hover) and (pointer: fine) {
63
63
  .option:not([data-selected]):hover { color: var(--text-secondary); }
@@ -67,7 +67,7 @@
67
67
  position: relative;
68
68
  display: inline-flex;
69
69
  align-items: center;
70
- gap: 8px;
70
+ gap: var(--space-related);
71
71
  transition: transform var(--duration-fast) var(--ease-standard);
72
72
  }
73
73
  .option:not([data-selected]):active .content { transform: scale(.97); }
@@ -79,7 +79,7 @@
79
79
  display: inline-flex;
80
80
  height: calc(var(--_h) - 10px);
81
81
  align-items: center;
82
- padding: 0 8px;
82
+ padding: 0 var(--space-related);
83
83
  border-radius: var(--radius-full); corner-shape: round;
84
84
  background: color-mix(in oklab, var(--foreground) 6%, transparent);
85
85
  color: var(--text-secondary);
@@ -108,7 +108,7 @@
108
108
 
109
109
  /* ------------------------------------------------------------------------ Price */
110
110
 
111
- .price { display: inline-flex; min-width: 0; flex-wrap: wrap; align-items: flex-end; gap: 4px 10px; }
111
+ .price { display: inline-flex; min-width: 0; flex-wrap: wrap; align-items: flex-end; gap: var(--space-tight) var(--space-group); }
112
112
  .amount {
113
113
  display: inline-flex;
114
114
  align-items: flex-start;
@@ -133,7 +133,7 @@
133
133
  white-space: nowrap;
134
134
  }
135
135
  .wasSlot { display: inline-block; overflow-x: clip; }
136
- .was { display: inline-block; padding-right: 6px; color: var(--text-muted); font-variant-numeric: tabular-nums; text-decoration-thickness: 1px; }
136
+ .was { display: inline-block; padding-right: var(--space-related); color: var(--text-muted); font-variant-numeric: tabular-nums; text-decoration-thickness: 1px; }
137
137
  .period { position: relative; display: inline-block; overflow-x: clip; overflow-clip-margin: 2px; }
138
138
  .periodSizer { display: inline-block; visibility: hidden; }
139
139
  .periodText { position: absolute; top: 0; left: 0; display: inline-block; }
@@ -1,6 +1,5 @@
1
1
  /* The dim follows the sheet's position in JS; only its color lives here. */
2
- .overlay { position: fixed; inset: 0; z-index: 50; background: oklch(16% 0 0 / .34); }
3
- :global(:root[data-theme="dark"]) .overlay { background: oklch(0% 0 0 / .52); }
2
+ .overlay { position: fixed; inset: 0; z-index: var(--layer-overlay); background: var(--scrim); }
4
3
  /* While it leaves, nothing underneath is blocked and the sheet cannot be grabbed again.
5
4
  Radix sets pointer-events: auto inline on the overlay, so only !important lets a click reach the trigger and reverse the exit. */
6
5
  .overlay[data-state="closed"], .sheet[data-state="closed"] { pointer-events: none !important; }
@@ -8,7 +7,7 @@
8
7
  /* The sheet is as tall as its largest detent plus a hidden extension below the fold; detents are translateY offsets from there. */
9
8
  .sheet {
10
9
  position: fixed;
11
- z-index: 51;
10
+ z-index: var(--layer-dialog);
12
11
  right: 0;
13
12
  bottom: calc(var(--sheet-extension) * -1);
14
13
  left: 0;
@@ -47,9 +46,9 @@
47
46
  .headRow { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-inset); }
48
47
  .headText { min-width: 0; }
49
48
  .title { margin: 0; font-family: var(--font-body); font-size: var(--text-lg); font-weight: 500; letter-spacing: var(--tracking-body); line-height: var(--leading-body); }
50
- .description { margin: 2px 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
49
+ .description { margin: var(--space-hair) 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
51
50
 
52
- .close { display: grid; flex: none; width: 2rem; height: 2rem; place-items: center; margin-top: -2px; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface-muted); color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
51
+ .close { display: grid; flex: none; width: 2rem; height: 2rem; place-items: center; margin-top: calc(var(--space-hair) * -1); padding: 0; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface-muted); color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
53
52
  @media (hover: hover) and (pointer: fine) { .close:hover { color: var(--foreground); border-color: var(--border-strong); } }
54
53
  .close:active { color: var(--foreground); }
55
54
 
@@ -6,8 +6,14 @@ export interface BreadcrumbItem {
6
6
  onClick?: (event: MouseEvent<HTMLElement>) => void;
7
7
  }
8
8
  export interface BreadcrumbProps {
9
+ /** The path from the top level to the current page, which is the last item. */
9
10
  items: BreadcrumbItem[];
11
+ /** The navigation landmark's name (default "Breadcrumb"). */
10
12
  ariaLabel?: string;
13
+ /** Experimental. The most crumbs to show, at least 2. A longer path shows the first crumb, a "…" menu of the levels
14
+ * between, and the last `maxItems - 1` crumbs. By default the path never collapses. */
15
+ maxItems?: number;
11
16
  }
12
- /** Crumbs present on first render stay still; crumbs added later slide in from the path before them. */
13
- export declare function Breadcrumb({ items, ariaLabel }: BreadcrumbProps): import("react").JSX.Element;
17
+ /** Crumbs present on first render stay still; crumbs added later slide in from the path before them.
18
+ * With maxItems, the levels a long path hides move into a "…" menu, where they stay links. */
19
+ export declare function Breadcrumb({ items, ariaLabel, maxItems }: BreadcrumbProps): import("react").JSX.Element;