@teamofsilicons/silicon-ui 0.2.0 → 0.4.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 (163) 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/animated-counter/animated-counter.js +1 -1
  6. package/components/announcement-bar/announcement-bar.module.css +6 -6
  7. package/components/avatar/avatar.d.ts +2 -2
  8. package/components/avatar/avatar.js +1 -1
  9. package/components/avatar/avatar.module.css +5 -2
  10. package/components/avatar-group/avatar-group.module.css +4 -4
  11. package/components/badge/badge.module.css +4 -4
  12. package/components/bar-chart/bar-chart.js +1 -1
  13. package/components/bar-chart/bar-chart.module.css +4 -4
  14. package/components/billing-toggle/billing-toggle.js +2 -2
  15. package/components/billing-toggle/billing-toggle.module.css +10 -10
  16. package/components/bottom-sheet/bottom-sheet.js +1 -1
  17. package/components/bottom-sheet/bottom-sheet.module.css +4 -5
  18. package/components/breadcrumb/breadcrumb.d.ts +8 -2
  19. package/components/breadcrumb/breadcrumb.js +21 -9
  20. package/components/breadcrumb/breadcrumb.module.css +7 -3
  21. package/components/brush-chart/brush-chart.module.css +9 -9
  22. package/components/button/button.d.ts +7 -2
  23. package/components/button/button.js +3 -1
  24. package/components/button/button.module.css +10 -1
  25. package/components/button-group/button-group.module.css +7 -7
  26. package/components/calendar/calendar.js +15 -5
  27. package/components/calendar/calendar.module.css +13 -10
  28. package/components/card/card.js +4 -4
  29. package/components/card/card.module.css +2 -2
  30. package/components/card-stack/card-stack.js +2 -2
  31. package/components/card-stack/card-stack.module.css +2 -3
  32. package/components/carousel/carousel.module.css +3 -3
  33. package/components/chat-thread/chat-thread.module.css +41 -41
  34. package/components/checkbox/checkbox.module.css +1 -1
  35. package/components/chip-group/chip-group.js +1 -1
  36. package/components/chip-group/chip-group.module.css +2 -2
  37. package/components/code-block/code-block.module.css +7 -14
  38. package/components/color-picker/color-picker.module.css +20 -21
  39. package/components/combobox/combobox.js +5 -1
  40. package/components/combobox/combobox.module.css +2 -2
  41. package/components/command-palette/command-palette.js +18 -11
  42. package/components/command-palette/command-palette.module.css +18 -18
  43. package/components/comment-thread/comment-thread.module.css +35 -35
  44. package/components/confirm-morph/confirm-morph.js +5 -5
  45. package/components/confirm-morph/confirm-morph.module.css +9 -9
  46. package/components/context-menu/context-menu.js +10 -28
  47. package/components/context-menu/context-menu.module.css +3 -3
  48. package/components/copy-button/copy-button.js +4 -4
  49. package/components/countdown/countdown.module.css +4 -4
  50. package/components/date-picker/date-picker.js +3 -3
  51. package/components/date-picker/date-picker.module.css +7 -7
  52. package/components/date-range-picker/date-range-picker.js +13 -10
  53. package/components/date-range-picker/date-range-picker.module.css +23 -22
  54. package/components/dialog/dialog.module.css +2 -2
  55. package/components/donut-chart/donut-chart.module.css +5 -5
  56. package/components/drawer/drawer.module.css +7 -7
  57. package/components/dropdown-menu/dropdown-menu.d.ts +71 -1
  58. package/components/dropdown-menu/dropdown-menu.js +100 -6
  59. package/components/dropdown-menu/dropdown-menu.module.css +15 -5
  60. package/components/elastic-slider/elastic-slider.js +2 -2
  61. package/components/elastic-slider/elastic-slider.module.css +4 -4
  62. package/components/expandable-card/expandable-card.js +1 -1
  63. package/components/expandable-card/expandable-card.module.css +1 -1
  64. package/components/expanding-button-group/expanding-button-group.module.css +3 -3
  65. package/components/expanding-search/expanding-search.js +3 -3
  66. package/components/expanding-search/expanding-search.module.css +9 -9
  67. package/components/file-dropzone/file-dropzone.js +3 -3
  68. package/components/file-dropzone/file-dropzone.module.css +21 -21
  69. package/components/file-upload/file-upload.module.css +7 -7
  70. package/components/filter-toolbar/filter-toolbar.js +7 -3
  71. package/components/filter-toolbar/filter-toolbar.module.css +9 -9
  72. package/components/floating-button-group/floating-button-group.module.css +7 -7
  73. package/components/gauge/gauge.js +1 -1
  74. package/components/gauge/gauge.module.css +2 -2
  75. package/components/hover-card/hover-card.js +2 -2
  76. package/components/hover-card/hover-card.module.css +2 -2
  77. package/components/image-compare/image-compare.module.css +4 -4
  78. package/components/inline-edit/inline-edit.module.css +12 -12
  79. package/components/json-viewer/json-viewer.module.css +12 -12
  80. package/components/line-chart/line-chart.js +1 -1
  81. package/components/line-chart/line-chart.module.css +8 -8
  82. package/components/mention-input/mention-input.js +4 -2
  83. package/components/mention-input/mention-input.module.css +8 -8
  84. package/components/metric-card/metric-card.module.css +1 -1
  85. package/components/money-input/money-input.js +6 -3
  86. package/components/money-input/money-input.module.css +10 -10
  87. package/components/morph-select/morph-select.js +7 -5
  88. package/components/morph-select/morph-select.module.css +13 -13
  89. package/components/multi-select/multi-select.js +4 -2
  90. package/components/multi-select/multi-select.module.css +6 -6
  91. package/components/nav-list/nav-list.d.ts +64 -0
  92. package/components/nav-list/nav-list.js +96 -0
  93. package/components/nav-list/nav-list.module.css +25 -0
  94. package/components/notification-center/notification-center.module.css +27 -27
  95. package/components/number-field/number-field.js +3 -3
  96. package/components/number-field/number-field.module.css +3 -3
  97. package/components/otp-input/otp-input.module.css +4 -4
  98. package/components/pagination/pagination.module.css +1 -1
  99. package/components/password-field/password-field.module.css +2 -2
  100. package/components/password-strength/password-strength.module.css +8 -8
  101. package/components/phone-input/phone-input.js +9 -7
  102. package/components/phone-input/phone-input.module.css +19 -19
  103. package/components/popover/popover.module.css +2 -2
  104. package/components/progress/progress.module.css +1 -1
  105. package/components/radio-cards/radio-cards.module.css +8 -8
  106. package/components/radio-group/radio-group.js +4 -1
  107. package/components/radio-group/radio-group.module.css +4 -3
  108. package/components/resizable-panels/resizable-panels.module.css +5 -5
  109. package/components/rich-text-editor/rich-text-editor.js +3 -3
  110. package/components/rich-text-editor/rich-text-editor.module.css +12 -12
  111. package/components/ridgeline/ridgeline.module.css +7 -7
  112. package/components/scroll-area/scroll-area.module.css +8 -8
  113. package/components/segmented-control/segmented-control.d.ts +3 -1
  114. package/components/segmented-control/segmented-control.js +5 -2
  115. package/components/segmented-control/segmented-control.module.css +9 -4
  116. package/components/select/select.module.css +5 -5
  117. package/components/shortcut-recorder/shortcut-recorder.js +5 -5
  118. package/components/shortcut-recorder/shortcut-recorder.module.css +16 -16
  119. package/components/signature-pad/signature-pad.module.css +11 -11
  120. package/components/slider/slider.module.css +4 -4
  121. package/components/slope-chart/slope-chart.module.css +7 -7
  122. package/components/slot-text/slot-text.js +2 -1
  123. package/components/sortable-data-table/sortable-data-table.module.css +4 -4
  124. package/components/sparkline/sparkline.js +1 -1
  125. package/components/split-button/split-button.module.css +6 -6
  126. package/components/stepper/stepper.module.css +3 -3
  127. package/components/streamgraph/streamgraph.module.css +7 -7
  128. package/components/swipe-actions/swipe-actions.js +2 -2
  129. package/components/swipe-actions/swipe-actions.module.css +5 -5
  130. package/components/switch/switch.js +2 -2
  131. package/components/switch/switch.module.css +1 -1
  132. package/components/tabs/tabs.d.ts +9 -2
  133. package/components/tabs/tabs.js +11 -5
  134. package/components/tabs/tabs.module.css +10 -3
  135. package/components/tag-input/tag-input.module.css +4 -4
  136. package/components/text-reveal/text-reveal.js +1 -1
  137. package/components/time-picker/time-picker.js +4 -2
  138. package/components/time-picker/time-picker.module.css +3 -3
  139. package/components/timeline/timeline.js +4 -4
  140. package/components/timeline/timeline.module.css +3 -3
  141. package/components/toast/toast.js +1 -1
  142. package/components/toast/toast.module.css +5 -5
  143. package/components/toast-stack/toast-stack.js +1 -1
  144. package/components/toast-stack/toast-stack.module.css +5 -5
  145. package/components/tooltip/tooltip.module.css +1 -1
  146. package/components/tree-view/tree-view.js +16 -12
  147. package/components/tree-view/tree-view.module.css +10 -8
  148. package/components/treemap/treemap.module.css +13 -13
  149. package/components/usage-meter/usage-meter.js +1 -1
  150. package/components/usage-meter/usage-meter.module.css +5 -5
  151. package/components/user-menu/user-menu.js +16 -14
  152. package/components/user-menu/user-menu.module.css +22 -22
  153. package/components/waffle-chart/waffle-chart.module.css +7 -7
  154. package/components.json +416 -1
  155. package/eslint.js +35 -3
  156. package/lib/fluid-hover.d.ts +83 -0
  157. package/lib/fluid-hover.js +184 -0
  158. package/lib/menu-highlight.d.ts +24 -11
  159. package/lib/menu-highlight.js +29 -28
  160. package/lib/motion-tokens.d.ts +76 -18
  161. package/lib/motion-tokens.js +45 -10
  162. package/package.json +1 -1
  163. package/styles.css +50 -7
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; } }
@@ -7,7 +7,7 @@ import styles from "./animated-counter.module.css";
7
7
  const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
8
8
  const rise = { hidden: { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } } };
9
9
  const fade = { hidden: { opacity: 0, y: 0, filter: "blur(0px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } } };
10
- const reveal = { ...motionTokens.spring.smooth, visualDuration: motionTokens.duration.considered };
10
+ const reveal = { ...motionTokens.spring.smooth, visualDuration: motionTokens.duration.expressive };
11
11
  /** Split a formatted number into columns keyed by place value, so 999 → 1,000 keeps the ones column the ones column. */
12
12
  function partsFor(value, decimals, locale) {
13
13
  const parts = new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, numberingSystem: "latn" }).formatToParts(value);
@@ -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
 
@@ -8,7 +8,7 @@ const { spring, duration, ease, blur, stagger } = motionTokens;
8
8
  /** Motion drops inherited velocity on time-defined springs, so values that retarget mid-flight run the same springs written as stiffness and damping. */
9
9
  const physical = ({ visualDuration, bounce }, restDelta = .01) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };
10
10
  const settle = physical(spring.smooth);
11
- const grow = physical(spring.morph);
11
+ const grow = physical(spring.expressive);
12
12
  const glide = physical(spring.snappy);
13
13
  /** The zoom runs in fractions of the plot, so it rests far below a pixel. */
14
14
  const zoom = physical(spring.smooth, .00001);
@@ -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; }
@@ -7,7 +7,7 @@ import { motionTokens } from "../../lib/motion-tokens.js";
7
7
  import styles from "./billing-toggle.module.css";
8
8
  const { duration, ease, blur } = motionTokens;
9
9
  /** Critically damped: the thumb glides and lands without overshoot. */
10
- const glide = { type: "spring", visualDuration: 0.34, bounce: 0 };
10
+ const glide = { type: "spring", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0 };
11
11
  const DEFAULT_OPTIONS = [
12
12
  { value: "monthly", label: "Monthly" },
13
13
  { value: "yearly", label: "Yearly", badge: "Save 20%" },
@@ -66,7 +66,7 @@ export function BillingToggle({ value, onValueChange, options = DEFAULT_OPTIONS,
66
66
  return (_jsxs("button", { ref: node => { refs.current[index] = node; }, type: "button", role: "radio", "aria-checked": selected, tabIndex: selected ? 0 : -1, className: styles.option, "data-selected": selected || undefined, onClick: () => onValueChange(option.value), onKeyDown: event => onKeyDown(event, index), children: [selected && !thumb ? _jsx("span", { className: styles.thumbStatic, "aria-hidden": "true" }) : null, _jsxs("span", { className: styles.content, children: [_jsx("span", { className: styles.label, children: option.label }), badgeText ? (_jsx("span", { className: styles.badge, "data-active": selected || undefined, children: _jsx(StableSwap, { text: badgeText, candidates: candidates, reduced: reduced }) })) : null] })] }, option.value));
67
67
  })] }));
68
68
  }
69
- const clip = { type: "spring", visualDuration: 0.36, bounce: 0 };
69
+ const clip = { type: "spring", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0 };
70
70
  /** A price that rolls to its new amount. The old price and the period ease their width, so nothing beside them jumps. */
71
71
  export function BillingPrice({ amount, currency = "$", period, was, decimals = 0, className }) {
72
72
  const reduced = !!useReducedMotion();
@@ -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; }
@@ -22,7 +22,7 @@ const STRETCH = 120;
22
22
  const LOW_DIM = .78;
23
23
  const settle = motionTokens.spring.smooth;
24
24
  /** Leaving is shorter than arriving; a flick keeps its velocity through the same spring. */
25
- const leave = { ...motionTokens.spring.smooth, visualDuration: .3 };
25
+ const leave = { ...motionTokens.spring.smooth, visualDuration: motionTokens.spring.moderate.visualDuration };
26
26
  const fade = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
27
27
  /** iOS-style resistance: follows the finger at first, then approaches STRETCH. */
28
28
  const rubber = (distance) => (1 - 1 / (distance * .55 / STRETCH + 1)) * STRETCH;
@@ -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; } }