@teamofsilicons/silicon-ui 0.2.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 +4 -3
  2. package/check.mjs +14 -5
  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 +35 -3
  111. package/package.json +1 -1
  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
 
@@ -71,7 +72,7 @@ export default [...yourConfig, ...siliconUi()];
71
72
  ```
72
73
 
73
74
  ```sh
74
- npx silicon-ui-check # raw spacing, radius, colour or font size in CSS modules fails
75
+ npx silicon-ui-check # raw spacing, radius, colour, font size or z-index in CSS modules fails
75
76
  npx silicon-ui-check --update-baseline # tolerate today's legacy values; only new drift fails
76
77
  ```
77
78
 
package/check.mjs CHANGED
@@ -2,15 +2,18 @@
2
2
  /**
3
3
  * silicon-ui-check: keeps an app's CSS on Silicon UI's tokens.
4
4
  *
5
- * Fails a CSS file that sets spacing, a corner radius, a colour or a font size with a raw value instead of a token:
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:
6
7
  *
7
8
  * spacing padding, margin, gap, inset, top/right/bottom/left, scroll-margin/padding → var(--space-*)
8
9
  * radius border-radius and its corners → var(--radius-*)
9
10
  * color hex, rgb(), hsl(), oklch(), oklab(), lab(), lch(), color() → var(--…)
10
11
  * font-size px or rem → var(--text-*)
12
+ * z-index 10 and above (or -10 and below) → var(--layer-*)
11
13
  *
12
14
  * Allowed everywhere: 0, auto, inherit and other keywords, anything inside var() (including fallbacks), and
13
- * font-relative or layout-relative units (em, %, ch, vw, vh, cq*). Custom property definitions (--x: …) are not
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
14
17
  * checked: tokens are defined that way, in the files you list under `ignore`. The visually hidden block (.srOnly,
15
18
  * .visuallyHidden, .sr-only) and @keyframes are skipped.
16
19
  *
@@ -43,10 +46,11 @@ const LENGTH = /(?<![\w.#-])(-?(?:\d*\.)?\d+)(px|rem)\b/g;
43
46
  const COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\(/i;
44
47
  const HIDDEN = /\.(?:srOnly|visuallyHidden|sr-only|visually-hidden)\b/;
45
48
  const FIX = {
46
- spacing: "use a spacing token: var(--space-tight|related|group|inset|comfort|section|block|page|stage|screen)",
49
+ spacing: "use a spacing token: var(--space-hair|tight|related|group|inset|comfort|section|block|page|stage|screen)",
47
50
  radius: "use a radius token: var(--radius-detail|control|container|overlay|full)",
48
51
  color: "use a colour token: var(--foreground), var(--text-secondary), var(--border), var(--accent) …",
49
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",
50
54
  };
51
55
 
52
56
  /** Removes every var(...) (with nested parentheses), so tokens and their fallbacks are never flagged. */
@@ -102,10 +106,15 @@ export function checkCss(css) {
102
106
  const bare = withoutVars(value);
103
107
  for (const [rule, pattern] of Object.entries(RULES)) {
104
108
  if (!pattern.test(property)) continue;
105
- const raw = [...bare.matchAll(LENGTH)].filter(match => Number(match[1]) !== 0).map(match => match[0]);
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]);
106
111
  if (raw.length) problems.push({ rule, line, property, value, raw: raw.join(", ") });
107
112
  }
108
- const color = bare.match(COLOR);
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);
109
118
  if (color) problems.push({ rule: "color", line, property, value, raw: color[0] });
110
119
  }
111
120
  return problems;
@@ -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;
@@ -1,19 +1,31 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { ChevronRight as NavArrowRight } from "lucide-react";
3
+ import { Ellipsis, ChevronRight as NavArrowRight } from "lucide-react";
4
4
  import { AnimatePresence, motion, useReducedMotion } from "motion/react";
5
+ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuRoot, DropdownMenuTrigger } from "../dropdown-menu/dropdown-menu.js";
5
6
  import { SiliconLink } from "../../lib/silicon-provider.js";
6
7
  import { motionTokens } from "../../lib/motion-tokens.js";
7
8
  import styles from "./breadcrumb.module.css";
8
- /** Crumbs present on first render stay still; crumbs added later slide in from the path before them. */
9
- export function Breadcrumb({ items, ariaLabel = "Breadcrumb" }) {
9
+ /** Crumbs present on first render stay still; crumbs added later slide in from the path before them.
10
+ * With maxItems, the levels a long path hides move into a "…" menu, where they stay links. */
11
+ export function Breadcrumb({ items, ariaLabel = "Breadcrumb", maxItems }) {
10
12
  const reduced = useReducedMotion() ?? false;
11
13
  const still = { duration: 0 };
12
14
  const path = items.map(item => item.label).join("/");
13
- return _jsx("nav", { "aria-label": ariaLabel, children: _jsx("ol", { className: styles.list, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: items.map((item, index) => {
14
- const current = index === items.length - 1;
15
- return _jsxs(motion.li, { layout: reduced ? false : "position", layoutDependency: path, initial: reduced ? false : { opacity: 0, x: -8, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, x: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: still } : { opacity: 0, x: -4, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, transition: reduced ? still : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], layout: motionTokens.spring.smooth }, children: [index > 0 && _jsx(NavArrowRight, { width: 14, height: 14, "aria-hidden": "true" }), !current && item.href ? _jsx(SiliconLink, { href: item.href, "data-label": item.label, onClick: item.onClick, children: item.label })
16
- : !current && item.onClick ? _jsx("button", { type: "button", "data-label": item.label, onClick: item.onClick, children: item.label })
17
- : _jsx("span", { "aria-current": current ? "page" : undefined, "data-label": item.label, children: item.label })] }, `${item.label}-${index}`);
18
- }) }) }) });
15
+ // The first crumb and the current page always show. Crumbs keep their index in `items` as their key, so the ones
16
+ // that stay visible never remount when the path collapses or grows.
17
+ const shown = Math.max(2, Math.floor(maxItems ?? Infinity));
18
+ const hidden = items.length > shown ? items.slice(1, items.length - shown + 1) : [];
19
+ const crumb = (key, first, children) => _jsxs(motion.li, { layout: reduced ? false : "position", layoutDependency: path, initial: reduced ? false : { opacity: 0, x: -8, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, x: 0, filter: "blur(0px)" }, exit: reduced ? { opacity: 0, transition: still } : { opacity: 0, x: -4, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, transition: reduced ? still : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], layout: motionTokens.spring.smooth }, children: [!first && _jsx(NavArrowRight, { width: 14, height: 14, "aria-hidden": "true" }), children] }, key);
20
+ const crumbs = items.map((item, index) => {
21
+ const current = index === items.length - 1;
22
+ return crumb(`${item.label}-${index}`, index === 0, !current && item.href ? _jsx(SiliconLink, { href: item.href, "data-label": item.label, onClick: item.onClick, children: item.label })
23
+ : !current && item.onClick ? _jsx("button", { type: "button", "data-label": item.label, onClick: item.onClick, children: item.label })
24
+ : _jsx("span", { "aria-current": current ? "page" : undefined, "data-label": item.label, children: item.label }));
25
+ });
26
+ if (hidden.length)
27
+ crumbs.splice(1, hidden.length, crumb("more", false, _jsxs(DropdownMenuRoot, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", className: styles.more, "aria-label": `Show ${hidden.length} more ${hidden.length === 1 ? "level" : "levels"}`, children: _jsx(Ellipsis, { width: 16, height: 16, "aria-hidden": "true" }) }) }), _jsx(DropdownMenuContent, { align: "start", children: hidden.map((item, index) => item.href
28
+ ? _jsx(DropdownMenuItem, { asChild: true, onClick: item.onClick, children: _jsx(SiliconLink, { href: item.href, children: item.label }) }, `${item.label}-${index + 1}`)
29
+ : _jsx(DropdownMenuItem, { disabled: !item.onClick, onClick: item.onClick, children: item.label }, `${item.label}-${index + 1}`)) })] })));
30
+ return _jsx("nav", { "aria-label": ariaLabel, children: _jsx("ol", { className: styles.list, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: crumbs }) }) });
19
31
  }
@@ -1,12 +1,16 @@
1
1
  /* The gap between crumbs matches the gap inside one, so every chevron sits centered between its two labels. */
2
- .list { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; margin: 0; padding: 0; list-style: none; }
3
- .list li { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); white-space: nowrap; }
2
+ .list { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-hair) var(--space-related); margin: 0; padding: 0; list-style: none; }
3
+ .list li { display: inline-flex; align-items: center; gap: var(--space-related); color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); white-space: nowrap; }
4
4
  .list li > svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--border-strong); stroke-width: 1.75; }
5
5
  /* Each crumb reserves the width of its medium weight label, so becoming the current page never shifts the path. */
6
- .list a, .list li > span, .list li > button { display: inline-flex; flex-direction: column; border-radius: var(--radius-detail); padding: 4px 2px; }
6
+ .list a, .list li > span, .list li > button { display: inline-flex; flex-direction: column; border-radius: var(--radius-detail); padding: var(--space-tight) var(--space-hair); }
7
7
  .list a::after, .list li > span::after, .list li > button::after { height: 0; overflow: hidden; content: attr(data-label); font-weight: 500; visibility: hidden; user-select: none; pointer-events: none; }
8
8
  .list li > button { border: 0; margin: 0; background: none; font: inherit; line-height: inherit; text-align: start; cursor: pointer; }
9
9
  .list a, .list li > button { color: var(--text-secondary); text-decoration: underline 1px; text-decoration-color: transparent; text-underline-offset: 4px; transition: color var(--duration-fast) var(--ease-standard), text-decoration-color var(--duration-fast) var(--ease-standard); }
10
10
  .list [aria-current="page"] { color: var(--accent-strong); font-weight: 500; transition: color var(--duration-fast) var(--ease-standard); }
11
+ /* The "…" of a collapsed path: a 24px square target, accented while its menu of hidden levels is open. */
12
+ .list .more { padding: var(--space-tight); }
13
+ .list .more svg { width: 16px; height: 16px; stroke-width: 1.75; }
14
+ .list .more[data-state="open"] { color: var(--accent-strong); }
11
15
  @media (hover: hover) and (pointer: fine) { .list a:hover, .list li > button:hover { color: var(--accent-strong); text-decoration-color: color-mix(in oklab, currentColor 45%, transparent); } }
12
16
  @media (prefers-reduced-motion: reduce) { .list a, .list li > button, .list [aria-current="page"] { transition: none; } }
@@ -19,8 +19,8 @@
19
19
  /* Events: a hairline through the plot, a dot in the second chart series on top, and a label when there is room for it. */
20
20
  .eventRule { stroke: var(--border-strong); stroke-width: 1; shape-rendering: crispEdges; transition: stroke var(--duration-fast) var(--ease-standard); }
21
21
  .eventRule[data-active] { stroke: var(--series-2); }
22
- .event { position: absolute; top: 6px; display: flex; box-sizing: border-box; min-height: 24px; align-items: center; gap: 6px; padding: 0 6px 0 0; margin-left: -12px; border: 0; background: transparent; color: var(--text-secondary); cursor: default; font: inherit; font-size: var(--text-xs); line-height: 1; white-space: nowrap; -webkit-tap-highlight-color: transparent; }
23
- .event[data-flip] { flex-direction: row-reverse; padding: 0 0 0 6px; margin-left: 0; translate: calc(-100% + 12px) 0; }
22
+ .event { position: absolute; top: var(--space-related); display: flex; box-sizing: border-box; min-height: 24px; align-items: center; gap: var(--space-related); padding: 0 var(--space-related) 0 0; margin-left: calc(var(--space-group) * -1); border: 0; background: transparent; color: var(--text-secondary); cursor: default; font: inherit; font-size: var(--text-xs); line-height: 1; white-space: nowrap; -webkit-tap-highlight-color: transparent; }
23
+ .event[data-flip] { flex-direction: row-reverse; padding: 0 0 0 var(--space-related); margin-left: 0; translate: calc(-100% + 12px) 0; }
24
24
  .eventDot { display: grid; width: 24px; height: 24px; flex: none; place-items: center; }
25
25
  .eventDot::before { width: 7px; height: 7px; border-radius: 50%; corner-shape: round; background: var(--series-2); box-shadow: 0 0 0 2px var(--surface); content: ""; transition: transform var(--duration-fast) var(--ease-standard); }
26
26
  .event[data-active] .eventDot::before { transform: scale(1.3); }
@@ -28,23 +28,23 @@
28
28
  .event[data-labelled] .eventLabel { opacity: 1; }
29
29
  .event[data-active] .eventLabel { color: var(--foreground); }
30
30
 
31
- .tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 132px; max-width: min(220px, 100%); gap: 2px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }
31
+ .tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 132px; max-width: min(220px, 100%); gap: var(--space-hair); padding: var(--space-group) var(--space-group); border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }
32
32
  .chart[data-active] .tooltip { opacity: 1; scale: 1; }
33
- .tipTitle { margin: 0 0 2px; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); white-space: nowrap; font-variant-numeric: tabular-nums; }
33
+ .tipTitle { margin: 0 0 var(--space-hair); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); white-space: nowrap; font-variant-numeric: tabular-nums; }
34
34
  .tipValue { margin: 0; color: var(--foreground); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
35
35
  .tipNote { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
36
- .tipEvent { display: flex; align-items: center; gap: 6px; margin: 4px 0 0; color: var(--foreground); font-size: var(--text-xs); line-height: var(--leading-body); }
36
+ .tipEvent { display: flex; align-items: center; gap: var(--space-related); margin: var(--space-tight) 0 0; color: var(--foreground); font-size: var(--text-xs); line-height: var(--leading-body); }
37
37
  .tipEventDot { width: 7px; height: 7px; flex: none; border-radius: 50%; corner-shape: round; background: var(--series-2); }
38
38
 
39
39
  .message { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); pointer-events: none; }
40
40
 
41
41
  .gutter { position: relative; grid-area: 1 / 2; min-width: 0; }
42
- .tick { position: absolute; left: 10px; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; translate: 0 -50%; pointer-events: none; }
42
+ .tick { position: absolute; left: var(--space-group); color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; translate: 0 -50%; pointer-events: none; }
43
43
  .axis { position: relative; grid-area: 2 / 1; min-width: 0; }
44
- .axisLabel { position: absolute; top: 9px; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; }
44
+ .axisLabel { position: absolute; top: var(--space-related); color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; }
45
45
 
46
46
  /* Overview: the whole history, with everything outside the window stepped back. */
47
- .strip { --strip-radius: var(--radius-detail); position: relative; grid-area: 3 / 1; min-width: 0; margin-top: 8px; border-radius: var(--strip-radius); background: color-mix(in oklab, var(--foreground) 2.5%, transparent); cursor: crosshair; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
47
+ .strip { --strip-radius: var(--radius-detail); position: relative; grid-area: 3 / 1; min-width: 0; margin-top: var(--space-related); border-radius: var(--strip-radius); background: color-mix(in oklab, var(--foreground) 2.5%, transparent); cursor: crosshair; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
48
48
  /* The overview drawing is clipped to the strip's corners; the handles stay outside the clip so they can overhang the ends. */
49
49
  .strip > .svg { clip-path: inset(0 round var(--strip-radius)); }
50
50
  .overviewLine { fill: none; stroke: color-mix(in oklab, var(--foreground) 28%, var(--surface)); stroke-width: 1; stroke-linejoin: round; }
@@ -57,7 +57,7 @@
57
57
  .shade[data-edge="end"] { border-radius: 0 var(--strip-radius) var(--strip-radius) 0; }
58
58
  .window { position: absolute; top: 0; bottom: 0; border-radius: var(--strip-radius); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--series-1) 45%, transparent); cursor: grab; outline: none; transition: box-shadow var(--duration-fast) var(--ease-standard); }
59
59
  .window:active { cursor: grabbing; }
60
- .handle { position: absolute; top: 50%; display: grid; width: 28px; height: 44px; place-items: center; margin-left: -14px; translate: 0 -50%; cursor: ew-resize; outline: none; touch-action: none; }
60
+ .handle { position: absolute; top: 50%; display: grid; width: 28px; height: 44px; place-items: center; margin-left: calc(var(--space-inset) * -1); translate: 0 -50%; cursor: ew-resize; outline: none; touch-action: none; }
61
61
  .grip { width: 6px; height: 22px; border-radius: var(--radius-full); corner-shape: round; background: var(--surface-raised); box-shadow: 0 0 0 1px color-mix(in oklab, var(--foreground) 26%, transparent), var(--shadow-resting); transition: transform var(--duration-instant) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
62
62
  .handle:active .grip { transform: scaleY(1.12); }
63
63
  @media (hover: hover) and (pointer: fine) { .handle:hover .grip { transform: scaleY(1.12); } }
@@ -1,9 +1,14 @@
1
1
  import type { ButtonHTMLAttributes } from "react";
2
- export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
3
- export type ButtonSize = "sm" | "md" | "lg";
2
+ /** `soft` is filled and borderless, for controls in a bar (experimental). */
3
+ export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "soft";
4
+ /** `xs` (24px) is for dense UI such as tree rows and toolbars, usually icon-only with an aria-label. */
5
+ export type ButtonSize = "xs" | "sm" | "md" | "lg";
4
6
  export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onDrag" | "onDragEnd" | "onDragStart" | "onAnimationStart"> {
7
+ /** The look: `primary` (default) for the main action, `secondary` bordered, `ghost` quiet, `danger` destructive, `soft` filled for bars. */
5
8
  variant?: ButtonVariant;
9
+ /** Height: `xs` 24px, `sm` 36px, `md` 44px (default), `lg` 50px. */
6
10
  size?: ButtonSize;
11
+ /** Shows a spinner in place of the label and sets aria-busy, keeping the button's width. */
7
12
  loading?: boolean;
8
13
  }
9
14
  export declare const Button: import("react").ForwardRefExoticComponent<ButtonProps & import("react").RefAttributes<HTMLButtonElement>>;
@@ -91,7 +91,9 @@ export const Button = forwardRef(function Button({ className, variant = "primary
91
91
  const anchorsLayer = (popup !== undefined && popup !== false && popup !== "false") || props.role === "combobox" || props["data-state"] !== undefined;
92
92
  const inert = disabled || loading || props["aria-disabled"] === true || props["aria-disabled"] === "true";
93
93
  const classes = [styles.button, styles[variant], styles[size], className].filter(Boolean).join(" ");
94
- return (_jsxs(motion.button, { ref: setRefs, tabIndex: props.tabIndex ?? 0, className: classes, "data-variant": variant, disabled: disabled, "aria-busy": loading || undefined, custom: buttonRef, variants: pressVariants, whileTap: reduceMotion || anchorsLayer || inert ? undefined : "pressed", transition: motionTokens.spring.snappy, ...props, "aria-disabled": loading || props["aria-disabled"] || undefined, onClick: loading ? event => event.preventDefault() : onClick, children: [_jsx(AnimatePresence, { initial: false, children: loading ? _jsx(motion.span, { className: styles.loader, "aria-hidden": "true", initial: reduceMotion ? { opacity: 0 } : { opacity: 0, scale: .6 }, animate: { opacity: 1, scale: 1 }, exit: reduceMotion ? fadeOut : { ...iconOut, scale: .8 }, transition: reduceMotion ? { duration: motionTokens.duration.instant } : iconEnter, children: _jsx("span", { className: styles.spinner }) }, "loader") : null }), _jsx(motion.span, { className: [styles.labelSlot, loading ? styles.loadingLabel : ""].filter(Boolean).join(" "), style: { width }, children: _jsx("span", { ref: contentRef, className: styles.labelContent, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(LabelPhase, { icon: !/\S/.test(key.replace(/<[^>]*>/g, "")), reduced: reduceMotion, children: children }, key) }) }) })] }));
94
+ // An icon with no text: square at the compact size.
95
+ const iconOnly = !/\S/.test(key.replace(/<[^>]*>/g, ""));
96
+ return (_jsxs(motion.button, { ref: setRefs, tabIndex: props.tabIndex ?? 0, className: classes, "data-variant": variant, "data-icon-only": iconOnly || undefined, disabled: disabled, "aria-busy": loading || undefined, custom: buttonRef, variants: pressVariants, whileTap: reduceMotion || anchorsLayer || inert ? undefined : "pressed", transition: motionTokens.spring.snappy, ...props, "aria-disabled": loading || props["aria-disabled"] || undefined, onClick: loading ? event => event.preventDefault() : onClick, children: [_jsx(AnimatePresence, { initial: false, children: loading ? _jsx(motion.span, { className: styles.loader, "aria-hidden": "true", initial: reduceMotion ? { opacity: 0 } : { opacity: 0, scale: .6 }, animate: { opacity: 1, scale: 1 }, exit: reduceMotion ? fadeOut : { ...iconOut, scale: .8 }, transition: reduceMotion ? { duration: motionTokens.duration.instant } : iconEnter, children: _jsx("span", { className: styles.spinner }) }, "loader") : null }), _jsx(motion.span, { className: [styles.labelSlot, loading ? styles.loadingLabel : ""].filter(Boolean).join(" "), style: { width }, children: _jsx("span", { ref: contentRef, className: styles.labelContent, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(LabelPhase, { icon: !/\S/.test(key.replace(/<[^>]*>/g, "")), reduced: reduceMotion, children: children }, key) }) }) })] }));
95
97
  });
96
98
  Button.displayName = "Button";
97
99
  export default Button;