@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
@@ -1,8 +1,15 @@
1
1
  import * as TabsPrimitive from "@radix-ui/react-tabs";
2
- import type { ComponentPropsWithoutRef } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  type RootProps = ComponentPropsWithoutRef<typeof TabsPrimitive.Root>;
4
4
  export declare function Tabs({ value, defaultValue, onValueChange, className, ...props }: RootProps): import("react").JSX.Element;
5
5
  export declare function TabsList({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.List>): import("react").JSX.Element;
6
- export declare function TabsTrigger({ className, children, value, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>): import("react").JSX.Element;
6
+ export interface TabsTriggerProps extends ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> {
7
+ /**
8
+ * An icon before the label, such as a 16px Lucide icon. It is sized, coloured with the label (muted, then the active
9
+ * colour when selected) and hidden from screen readers. For an icon-only tab, leave out the label and pass `aria-label`.
10
+ */
11
+ icon?: ReactNode;
12
+ }
13
+ export declare function TabsTrigger({ className, children, value, icon, ...props }: TabsTriggerProps): import("react").JSX.Element;
7
14
  export declare function TabsContent({ className, value, forceMount, children, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Content>): import("react").JSX.Element;
8
15
  export {};
@@ -6,6 +6,7 @@ import { createContext, useCallback, useContext, useId, useLayoutEffect, useRef,
6
6
  import { ChevronLeft as NavArrowLeft, ChevronRight as NavArrowRight } from "lucide-react";
7
7
  import { AnimatePresence, LayoutGroup, animate, motion, useReducedMotion } from "motion/react";
8
8
  import { motionTokens } from "../../lib/motion-tokens.js";
9
+ import { FluidHoverHighlight, useFluidHover } from "../../lib/fluid-hover.js";
9
10
  import styles from "./tabs.module.css";
10
11
  /** `direction` is +1 when the new tab sits after the old one; `panelHeightRef` holds the visible panel height so the next panel can morph from it; `leavingRectRef` pins the outgoing panel where it was on screen. */
11
12
  const TabsContext = createContext({ active: "", layoutId: "tabs", direction: 1, panelHeightRef: { current: null }, leavingRectRef: { current: null } });
@@ -37,6 +38,9 @@ export function TabsList({ className, ...props }) {
37
38
  const viewport = useRef(null);
38
39
  const list = useRef(null);
39
40
  const [edges, setEdges] = useState({ overflow: false, left: false, right: false });
41
+ // Fluid hover: the nearest tab is lit, gaps included. Radix selects on mouse-down, so a gap click stays inert, and focus
42
+ // already shows the selection, so the keyboard leaves the hover fill alone.
43
+ const hover = useFluidHover(list, { items: '[role="tab"]', axis: "x", gapClick: false, followFocus: false });
40
44
  const update = useCallback(() => {
41
45
  const frame = shell.current;
42
46
  const scroll = viewport.current;
@@ -75,14 +79,16 @@ export function TabsList({ className, ...props }) {
75
79
  }, [update]);
76
80
  useLayoutEffect(() => { reveal(list.current?.querySelector('[role="tab"][data-state="active"]') ?? null); }, [active, reveal]);
77
81
  const scrollTabs = (direction) => viewport.current?.scrollBy({ left: direction * (viewport.current?.clientWidth ?? 0) * .75, behavior: reduced ? "instant" : "smooth" });
78
- return _jsxs("div", { ref: shell, className: styles.listShell, "data-overflow": edges.overflow, "data-left": edges.left, "data-right": edges.right, children: [edges.overflow && _jsx("button", { type: "button", className: `${styles.scrollButton} ${styles.scrollLeft}`, "aria-label": "Scroll tabs left", disabled: !edges.left, onClick: () => scrollTabs(-1), children: _jsx(NavArrowLeft, { width: 17, height: 17, "aria-hidden": "true" }) }), _jsx(motion.div, { ref: viewport, layoutScroll: true, className: styles.viewport, onFocusCapture: event => { if (event.target instanceof HTMLElement && event.target.getAttribute("role") === "tab")
79
- reveal(event.target); }, children: _jsx(TabsPrimitive.List, { ...props, ref: list, className: [styles.list, className].filter(Boolean).join(" ") }) }), edges.overflow && _jsx("button", { type: "button", className: `${styles.scrollButton} ${styles.scrollRight}`, "aria-label": "Scroll tabs right", disabled: !edges.right, onClick: () => scrollTabs(1), children: _jsx(NavArrowRight, { width: 17, height: 17, "aria-hidden": "true" }) })] });
82
+ return _jsxs("div", { ref: shell, className: styles.listShell, "data-overflow": edges.overflow, "data-left": edges.left, "data-right": edges.right, children: [edges.overflow && _jsx("button", { type: "button", className: `${styles.scrollButton} ${styles.scrollLeft}`, "aria-label": "Scroll tabs left", disabled: !edges.left, onClick: () => scrollTabs(-1), children: _jsx(NavArrowLeft, { width: 17, height: 17, "aria-hidden": "true" }) }), _jsx(motion.div, { ref: viewport, layoutScroll: true, className: styles.viewport, ...hover.handlers, onFocusCapture: event => { if (event.target instanceof HTMLElement && event.target.getAttribute("role") === "tab")
83
+ reveal(event.target); }, children: _jsxs(TabsPrimitive.List, { ...props, ref: list, className: [styles.list, className].filter(Boolean).join(" "), children: [_jsx(FluidHoverHighlight, { hover: hover, className: styles.hover }), props.children] }) }), edges.overflow && _jsx("button", { type: "button", className: `${styles.scrollButton} ${styles.scrollRight}`, "aria-label": "Scroll tabs right", disabled: !edges.right, onClick: () => scrollTabs(1), children: _jsx(NavArrowRight, { width: 17, height: 17, "aria-hidden": "true" }) })] });
80
84
  }
81
- export function TabsTrigger({ className, children, value, ...props }) {
85
+ export function TabsTrigger({ className, children, value, icon, ...props }) {
82
86
  const { active } = useContext(TabsContext);
83
87
  const reduced = useReducedMotion();
84
88
  // The LayoutGroup in Tabs scopes the highlight to this instance, so it glides between triggers but never flies in from another tab set.
85
- return _jsxs(TabsPrimitive.Trigger, { ...props, value: value, "data-value": value, className: [styles.trigger, className].filter(Boolean).join(" "), children: [active === value && _jsx(motion.span, { className: styles.selection, layoutId: "selection", layoutDependency: active, transition: reduced ? { duration: 0 } : motionTokens.spring.morph, "aria-hidden": "true" }), _jsx("span", { className: styles.triggerLabel, children: children })] });
89
+ // An icon with no label is a square tab instead of a text tab's minimum width.
90
+ const iconOnly = !!icon && (children === undefined || children === null || children === false);
91
+ return _jsxs(TabsPrimitive.Trigger, { ...props, value: value, "data-value": value, "data-icon-only": iconOnly || undefined, className: [styles.trigger, className].filter(Boolean).join(" "), children: [active === value && _jsx(motion.span, { className: styles.selection, layoutId: "selection", layoutDependency: active, transition: reduced ? { duration: 0 } : motionTokens.spring.morph, "aria-hidden": "true" }), _jsxs("span", { className: styles.triggerLabel, children: [icon ? _jsx("span", { className: styles.icon, "aria-hidden": "true", children: icon }) : null, children] })] });
86
92
  }
87
93
  const panelMotion = {
88
94
  enter: (direction) => ({ opacity: 0, x: direction * 8 }),
@@ -93,7 +99,7 @@ const panelMotion = {
93
99
  const panelFade = {
94
100
  enter: { opacity: 0, x: 0 },
95
101
  center: { opacity: 1, x: 0, transition: { duration: motionTokens.duration.instant } },
96
- exit: { opacity: 0, x: 0, transition: { duration: .1 } },
102
+ exit: { opacity: 0, x: 0, transition: { duration: motionTokens.duration.instant } },
97
103
  };
98
104
  export function TabsContent({ className, value, forceMount, children, ...props }) {
99
105
  const { active, direction, panelHeightRef, leavingRectRef } = useContext(TabsContext);
@@ -7,17 +7,24 @@
7
7
  .listShell[data-right="true"] .viewport { mask-image: linear-gradient(to right, black, black calc(100% - 35px), transparent); }
8
8
  .listShell[data-left="true"][data-right="true"] .viewport { mask-image: linear-gradient(to right, transparent, black 35px, black calc(100% - 35px), transparent); }
9
9
  /* The list owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */
10
- .list { isolation: isolate; display: inline-flex; width: max-content; align-items: center; gap: var(--space-tight); padding: var(--space-tight); }
10
+ .list { position: relative; isolation: isolate; display: inline-flex; width: max-content; align-items: center; gap: var(--space-tight); padding: var(--space-tight); }
11
11
  .scrollButton { position: absolute; z-index: 2; top: 0; bottom: 0; display: grid; width: 33px; place-items: center; border: 0; background: var(--surface-muted); color: var(--foreground); }
12
12
  .scrollLeft { left: 0; border-radius: var(--radius-control) 0 0 var(--radius-control); }
13
13
  .scrollRight { right: 0; border-radius: 0 var(--radius-control) var(--radius-control) 0; }
14
14
  .scrollButton:disabled { opacity: 0; pointer-events: none; }
15
15
  .scrollButton:focus-visible { --focus-outline-offset: -3px; }
16
- .trigger { position: relative; -webkit-tap-highlight-color: transparent; display: inline-grid; min-width: 5.5rem; place-items: center; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - 3px); padding: 0 var(--space-group); background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }
16
+ .trigger { position: relative; -webkit-tap-highlight-color: transparent; display: inline-grid; min-width: 5.5rem; place-items: center; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - var(--space-tight)); padding: 0 var(--space-group); background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }
17
17
  @media (hover: hover) and (pointer: fine) { .trigger:hover { color: var(--foreground); } }
18
18
  .trigger:active { color: var(--foreground); }.trigger[data-state="active"] { color: var(--accent-strong); }
19
+ /* The hover fill glides under the tabs and under the selection, so the selected tab never changes on hover. */
20
+ .hover { z-index: -1; border-radius: calc(var(--radius-control) - var(--space-tight)); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--foreground) 5%, transparent); }
19
21
  .selection { position: absolute; z-index: -1; inset: 0; border: 1px solid var(--border); border-radius: inherit; corner-shape: inherit; background: var(--surface); box-shadow: var(--shadow-resting); will-change: transform; }
20
- .triggerLabel { position: relative; z-index: 1; }
22
+ .triggerLabel { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: var(--space-related); }
23
+ /* An icon before the label: one size for every tab set, coloured with the label so it follows hover and selection. */
24
+ .icon { display: inline-grid; flex: none; place-items: center; color: currentColor; }
25
+ .icon svg { width: 16px; height: 16px; }
26
+ /* Icon-only tabs are square, the height of the strip's controls. */
27
+ .trigger[data-icon-only] { min-width: var(--control-height-sm); padding: 0; }
21
28
  .trigger:disabled { cursor: not-allowed; opacity: .5; }
22
29
  .content { margin-top: var(--space-inset); color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
23
30
  /* The outgoing panel is lifted out of flow by the presence animation; drop its margin so it stays in place and ignore stray clicks. */
@@ -7,18 +7,18 @@
7
7
  @media (hover: hover) and (pointer: fine) { .control:hover:not(:focus-within) { border-color: var(--border-strong); } }
8
8
  .control:focus-within { border-color: var(--foreground); box-shadow: 0 0 0 3px var(--focus-ring); outline: 2px solid transparent; outline-offset: 2px; }
9
9
  /* Equal 7px padding makes one row fill the minimum height exactly, so existing tags hold their line when a second row opens. */
10
- .content { position: relative; display: flex; flex-wrap: wrap; align-items: center; align-content: flex-start; gap: 6px; min-height: calc(var(--control-height-md) - 2px); box-sizing: border-box; padding: 7px; cursor: text; }
11
- .tag { position: relative; display: inline-flex; align-items: center; gap: 3px; min-height: 28px; box-sizing: border-box; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; padding: 0 4px 0 9px; background: var(--surface-muted); color: var(--foreground); font-size: var(--text-sm); white-space: nowrap; cursor: default; }
10
+ .content { position: relative; display: flex; flex-wrap: wrap; align-items: center; align-content: flex-start; gap: var(--space-related); min-height: calc(var(--control-height-md) - 2px); box-sizing: border-box; padding: var(--space-related); cursor: text; }
11
+ .tag { position: relative; display: inline-flex; align-items: center; gap: var(--space-tight); min-height: 28px; box-sizing: border-box; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; padding: 0 var(--space-tight) 0 var(--space-related); background: var(--surface-muted); color: var(--foreground); font-size: var(--text-sm); white-space: nowrap; cursor: default; }
12
12
  .tag button { display: grid; width: 22px; height: 22px; place-items: center; border: 0; border-radius: 50%; corner-shape: round; background: transparent; color: var(--text-secondary); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
13
13
  @media (hover: hover) and (pointer: fine) { .tag button:hover { background: var(--surface); color: var(--foreground); } }
14
14
  .tag button:focus-visible { background: var(--surface); color: var(--foreground); outline: none; box-shadow: inset 0 0 0 1.5px var(--focus-ring); }
15
15
  /* Quick press, spring release. */
16
16
  .tag button:active { transform: scale(.96); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
17
- .content input { flex: 1 1 100px; min-width: 80px; min-height: 28px; box-sizing: border-box; border: 0; padding: 0 5px; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); }
17
+ .content input { flex: 1 1 100px; min-width: 80px; min-height: 28px; box-sizing: border-box; border: 0; padding: 0 var(--space-tight); background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); }
18
18
  .content input:focus, .content input:focus-visible { outline: none; }
19
19
  .content input::placeholder { color: transparent; }
20
20
  /* The visible placeholder is drawn here so it can rise in when the last tag leaves. The native one stays for assistive tech. Inset matches the content and input padding. */
21
- .placeholder { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; padding: 0 12px; color: var(--text-muted); font-size: var(--text-sm); white-space: nowrap; pointer-events: none; }
21
+ .placeholder { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; padding: 0 var(--space-group); color: var(--text-muted); font-size: var(--text-sm); white-space: nowrap; pointer-events: none; }
22
22
  /* The pick ring rides above the pills and sizes itself to the picked tag, so it can glide between tags without scaling its border. */
23
23
  .ring { position: absolute; top: 0; left: 0; z-index: 1; box-sizing: border-box; border: 1px solid var(--accent); border-radius: var(--radius-full); corner-shape: round; background: var(--accent-subtle); opacity: 0; pointer-events: none; }
24
24
  /* A resting tag is not a stacking context (its filter ends at none), so its label and button sit above the ring while its fill stays below. */
@@ -4,7 +4,7 @@ import { Fragment } from "react";
4
4
  import { motionTokens } from "../../lib/motion-tokens.js";
5
5
  import styles from "./text-reveal.module.css";
6
6
  /** Total stagger stays under this many seconds, however long the text is. */
7
- const MAX_STAGGER = motionTokens.duration.considered;
7
+ const MAX_STAGGER = motionTokens.duration.expressive;
8
8
  export function TextReveal({ text, as = "h2", className, id, delay = 0 }) {
9
9
  const Tag = as;
10
10
  const lines = text.split("\n").map(line => line.split(" ").filter(Boolean));
@@ -4,6 +4,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
4
4
  import { ChevronDown, Clock3 } from "lucide-react";
5
5
  import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
6
6
  import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import { useNearestItem } from "../../lib/fluid-hover.js";
7
8
  import styles from "./time-picker.module.css";
8
9
  const pad = (value) => String(value).padStart(2, "0");
9
10
  const toMinutes = (value) => { const [h = NaN, m = NaN] = value.split(":").map(Number); return Number.isFinite(h) && Number.isFinite(m) ? h * 60 + m : 0; };
@@ -27,6 +28,8 @@ export function TimePicker({ label, value, defaultValue = "09:00", onChange, des
27
28
  const [internal, setInternal] = useState(defaultValue);
28
29
  const [open, setOpen] = useState(false);
29
30
  const [activeIndex, setActiveIndex] = useState(-1);
31
+ // Fluid hover: the mouse makes the nearest time active, gaps and padding included, and a click there picks it.
32
+ const nearest = useNearestItem({ items: '[role="option"]', onPick: item => setActiveIndex(Number(item.dataset.index)) });
30
33
  const selected = value ?? internal;
31
34
  const reduce = useReducedMotion();
32
35
  const [previous, setPrevious] = useState(selected);
@@ -90,6 +93,5 @@ export function TimePicker({ label, value, defaultValue = "09:00", onChange, des
90
93
  setActiveIndex(index => (index + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length);
91
94
  }
92
95
  };
93
- return _jsxs("div", { ref: rootRef, className: [styles.field, className].filter(Boolean).join(" "), children: [_jsx("span", { id: labelId, className: styles.label, children: label }), _jsxs("div", { className: styles.anchor, children: [_jsxs("button", { type: "button", className: styles.trigger, disabled: disabled, "aria-labelledby": `${labelId} ${valueId}`, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": `${id}-listbox`, onClick: () => open ? setOpen(false) : openMenu(), onKeyDown: onKeyDown, children: [_jsx(Clock3, { size: 16, "aria-hidden": "true" }), _jsx("span", { id: valueId, className: styles.srOnly, children: selected ? display(selected) : placeholder }), _jsx("span", { className: styles.valueText, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, custom: direction, children: _jsx(motion.span, { className: selected ? styles.value : styles.placeholder, custom: direction, variants: reduce ? valueFade : valueRoll, initial: "enter", animate: "center", exit: "exit", children: selected ? display(selected) : placeholder }, selected || "placeholder") }) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, "aria-hidden": "true" })] }), _jsx(AnimatePresence, { initial: false, children: open && _jsx(motion.div, { id: `${id}-listbox`, className: styles.menu, role: "listbox", "aria-label": `${label} options`, initial: reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: .97 }, animate: { opacity: 1, y: 0, scale: 1, transition: reduce ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } } }, exit: { opacity: 0, ...(reduce ? {} : { y: -4, scale: .98 }), transition: { duration: motionTokens.duration.instant, ease: standard } }, children: options.map((option, index) => _jsxs("button", { ref: node => { optionRefs.current[index] = node; }, id: `${id}-option-${index}`, type: "button", role: "option", "aria-selected": option === selected, "data-active": activeIndex === index || undefined, className: styles.option, onPointerMove: () => { if (activeIndex !== index)
94
- setActiveIndex(index); }, onClick: () => choose(option), children: [display(option), option === selected && _jsx("span", { className: styles.dot, "aria-hidden": "true" })] }, option)) }) })] }), description && _jsx("span", { className: styles.description, children: description })] });
96
+ return _jsxs("div", { ref: rootRef, className: [styles.field, className].filter(Boolean).join(" "), children: [_jsx("span", { id: labelId, className: styles.label, children: label }), _jsxs("div", { className: styles.anchor, children: [_jsxs("button", { type: "button", className: styles.trigger, disabled: disabled, "aria-labelledby": `${labelId} ${valueId}`, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": `${id}-listbox`, onClick: () => open ? setOpen(false) : openMenu(), onKeyDown: onKeyDown, children: [_jsx(Clock3, { size: 16, "aria-hidden": "true" }), _jsx("span", { id: valueId, className: styles.srOnly, children: selected ? display(selected) : placeholder }), _jsx("span", { className: styles.valueText, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, custom: direction, children: _jsx(motion.span, { className: selected ? styles.value : styles.placeholder, custom: direction, variants: reduce ? valueFade : valueRoll, initial: "enter", animate: "center", exit: "exit", children: selected ? display(selected) : placeholder }, selected || "placeholder") }) }), _jsx(ChevronDown, { className: styles.chevron, size: 16, "aria-hidden": "true" })] }), _jsx(AnimatePresence, { initial: false, children: open && _jsx(motion.div, { id: `${id}-listbox`, className: styles.menu, role: "listbox", "aria-label": `${label} options`, ...nearest, initial: reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: .97 }, animate: { opacity: 1, y: 0, scale: 1, transition: reduce ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } } }, exit: { opacity: 0, ...(reduce ? {} : { y: -4, scale: .98 }), transition: { duration: motionTokens.duration.instant, ease: standard } }, children: options.map((option, index) => _jsxs("button", { ref: node => { optionRefs.current[index] = node; }, id: `${id}-option-${index}`, type: "button", role: "option", "aria-selected": option === selected, "data-active": activeIndex === index || undefined, className: styles.option, "data-index": index, onClick: () => choose(option), children: [display(option), option === selected && _jsx("span", { className: styles.dot, "aria-hidden": "true" })] }, option)) }) })] }), description && _jsx("span", { className: styles.description, children: description })] });
95
97
  }
@@ -2,7 +2,7 @@
2
2
  .label { font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
3
3
  .anchor { position: relative; min-width: 0; }
4
4
  /* The trigger anchors the menu, so press feedback stays in color; it never scales. */
5
- .trigger { position: relative; display: flex; width: 100%; min-height: var(--control-height-md); align-items: center; gap: 9px; border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); padding: 0 12px; background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
5
+ .trigger { position: relative; display: flex; width: 100%; min-height: var(--control-height-md); align-items: center; gap: var(--space-related); border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); padding: 0 var(--space-group); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
6
6
  @media (hover: hover) and (pointer: fine) { .trigger:hover:not(:disabled) { border-color: var(--border-strong); background: var(--surface-muted); } }
7
7
  .trigger:active:not(:disabled), .trigger[aria-expanded="true"] { border-color: var(--border-strong); background: var(--surface-muted); }
8
8
  .trigger:disabled { opacity: .5; cursor: not-allowed; }
@@ -15,9 +15,9 @@
15
15
  .chevron { flex: 0 0 auto; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring); }
16
16
  .trigger[aria-expanded="true"] .chevron { transform: rotate(180deg); }
17
17
  .description { font-size: var(--text-xs); }
18
- .menu { position: absolute; z-index: 80; top: calc(100% + 6px); right: 0; left: 0; max-height: 250px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: var(--radius-container); corner-shape: var(--corner-shape); padding: 5px; background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: top center; will-change: transform, opacity; }
18
+ .menu { position: absolute; z-index: var(--layer-dropdown); top: calc(100% + var(--space-related)); right: 0; left: 0; max-height: 250px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: var(--radius-container); corner-shape: var(--corner-shape); padding: var(--space-tight); background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: top center; will-change: transform, opacity; }
19
19
  /* Arrow keys move the highlight often, so it changes almost instantly. The dot marks the chosen time, so only one row is ever highlighted. */
20
- .option { display: flex; width: 100%; min-height: 36px; align-items: center; justify-content: space-between; border: 0; border-radius: calc(var(--radius-container) - 5px); corner-shape: var(--corner-shape); padding: 0 12px; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); font-variant-numeric: tabular-nums; text-align: left; cursor: pointer; transition: background-color 80ms var(--ease-standard); }
20
+ .option { display: flex; width: 100%; min-height: 36px; align-items: center; justify-content: space-between; border: 0; border-radius: calc(var(--radius-container) - var(--space-tight)); corner-shape: var(--corner-shape); padding: 0 var(--space-group); background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); font-variant-numeric: tabular-nums; text-align: left; cursor: pointer; transition: background-color 80ms var(--ease-standard); }
21
21
  .option[data-active="true"] { background: var(--surface-muted); }
22
22
  .dot { width: 6px; height: 6px; border-radius: var(--radius-full); corner-shape: round; background: var(--foreground); }
23
23
  @media (prefers-reduced-motion: reduce) { .trigger, .chevron, .option { transition: none; } }
@@ -51,7 +51,7 @@ function relative(time, now) {
51
51
  /** A day's section. A new day opens from nothing and pushes the older days down; it clips only while it grows. */
52
52
  function DaySection({ id, fresh, reduced, children }) {
53
53
  const [entering, setEntering] = useState(fresh);
54
- return _jsx(motion.section, { className: styles.group, "aria-labelledby": id, "data-entering": entering || undefined, initial: fresh ? { height: 0, opacity: 0 } : false, animate: { height: "auto", opacity: 1 }, exit: { height: 0, opacity: 0, transition: { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.exit, ease: standard } }, transition: reduced ? { duration: .15 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter } }, onAnimationComplete: () => setEntering(false), children: children });
54
+ return _jsx(motion.section, { className: styles.group, "aria-labelledby": id, "data-entering": entering || undefined, initial: fresh ? { height: 0, opacity: 0 } : false, animate: { height: "auto", opacity: 1 }, exit: { height: 0, opacity: 0, transition: { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.exit, ease: standard } }, transition: reduced ? { duration: motionTokens.duration.fast } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter } }, onAnimationComplete: () => setEntering(false), children: children });
55
55
  }
56
56
  function TimelineRow({ row, last, expanded, onToggle, timeLabel, timeFull }) {
57
57
  const clock = useContext(RevealClock);
@@ -63,11 +63,11 @@ function TimelineRow({ row, last, expanded, onToggle, timeLabel, timeFull }) {
63
63
  const [entering, setEntering] = useState(fresh);
64
64
  const shown = revealDelay !== null;
65
65
  const delay = (revealDelay ?? 0) + (fresh ? .12 : 0);
66
- const pop = reduced ? { duration: 0, opacity: { duration: .15 } } : { ...motionTokens.spring.morph, visualDuration: .36, bounce: .32, delay, opacity: { duration: motionTokens.duration.fast, ease: enter, delay } };
67
- const draw = reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: .34, delay: delay + .1 };
66
+ const pop = reduced ? { duration: 0, opacity: { duration: motionTokens.duration.fast } } : { ...motionTokens.spring.morph, visualDuration: motionTokens.spring.slow.visualDuration, bounce: .32, delay, opacity: { duration: motionTokens.duration.fast, ease: enter, delay } };
67
+ const draw = reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: motionTokens.spring.moderate.visualDuration, delay: delay + .1 };
68
68
  const tone = row.avatar ? undefined : row.tone ?? "neutral";
69
69
  const Trigger = row.detail ? "button" : "div";
70
- return _jsxs(motion.li, { className: styles.item, "data-entering": entering || undefined, initial: fresh ? { height: 0 } : false, animate: { height: "auto" }, exit: { height: 0, opacity: 0, transition: reduced ? { duration: .1 } : { height: { ...motionTokens.spring.smooth, visualDuration: .3 }, opacity: { duration: .12 } } }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, onAnimationComplete: () => setEntering(false), onViewportEnter: () => setRevealDelay(current => current ?? clock.schedule()), viewport: { once: true, amount: .2 }, children: [_jsx(motion.span, { className: styles.marker, "data-tone": tone, "aria-hidden": "true", initial: { scale: .4, opacity: 0 }, animate: shown ? { scale: 1, opacity: 1 } : undefined, transition: pop, children: row.avatar ? _jsx("img", { src: row.avatar, alt: "", width: 28, height: 28, decoding: "async" }) : row.icon }), !last && _jsx(motion.span, { className: styles.segment, "aria-hidden": "true", initial: { scaleY: 0 }, animate: shown ? { scaleY: 1 } : undefined, transition: draw }), _jsxs(motion.div, { className: styles.content, initial: fresh ? (reduced ? { opacity: 0 } : { opacity: 0, y: -10, filter: `blur(${motionTokens.blur.soft}px)` }) : false, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, transition: reduced ? { duration: .15 } : { y: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter, delay: .05 }, filter: { duration: motionTokens.duration.standard, ease: enter, delay: .05 } }, children: [_jsxs(Trigger, { className: styles.trigger, ...(row.detail ? { type: "button", "data-timeline-trigger": "", "aria-expanded": expanded, "aria-controls": expanded ? detailId : undefined, onClick: onToggle } : {}), children: [_jsxs("span", { className: styles.text, children: [_jsxs("span", { className: styles.title, children: [row.actor && _jsx("span", { className: styles.actor, children: row.actor }), row.actor ? " " : "", row.title] }), row.meta && _jsx("span", { className: styles.meta, children: row.meta })] }), _jsxs("time", { className: styles.time, dateTime: new Date(row.time).toISOString(), title: timeFull, children: [_jsx(RiseText, { text: timeLabel, reduced: reduced }), _jsx("span", { className: styles.srOnly, children: timeFull })] }), row.detail && _jsx(motion.span, { className: styles.chevron, "aria-hidden": "true", initial: false, animate: { rotate: expanded ? 180 : 0 }, transition: reduced ? { duration: 0 } : motionTokens.spring.snappy, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) })] }), _jsx(AnimatePresence, { initial: false, children: expanded && row.detail && _jsx(motion.div, { id: detailId, className: styles.detail, initial: { height: 0 }, animate: { height: "auto" }, exit: { height: 0, transition: reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: .28 } }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, children: _jsx(motion.div, { className: styles.detailInner, initial: reduced ? { opacity: 0 } : { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant, ease: standard } }, transition: reduced ? { duration: .15 } : { duration: motionTokens.duration.standard, ease: enter, delay: .06 }, children: row.detail }) }, "detail") })] })] });
70
+ return _jsxs(motion.li, { className: styles.item, "data-entering": entering || undefined, initial: fresh ? { height: 0 } : false, animate: { height: "auto" }, exit: { height: 0, opacity: 0, transition: reduced ? { duration: motionTokens.duration.instant } : { height: { ...motionTokens.spring.smooth, visualDuration: motionTokens.spring.moderate.visualDuration }, opacity: { duration: motionTokens.duration.instant } } }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, onAnimationComplete: () => setEntering(false), onViewportEnter: () => setRevealDelay(current => current ?? clock.schedule()), viewport: { once: true, amount: .2 }, children: [_jsx(motion.span, { className: styles.marker, "data-tone": tone, "aria-hidden": "true", initial: { scale: .4, opacity: 0 }, animate: shown ? { scale: 1, opacity: 1 } : undefined, transition: pop, children: row.avatar ? _jsx("img", { src: row.avatar, alt: "", width: 28, height: 28, decoding: "async" }) : row.icon }), !last && _jsx(motion.span, { className: styles.segment, "aria-hidden": "true", initial: { scaleY: 0 }, animate: shown ? { scaleY: 1 } : undefined, transition: draw }), _jsxs(motion.div, { className: styles.content, initial: fresh ? (reduced ? { opacity: 0 } : { opacity: 0, y: -10, filter: `blur(${motionTokens.blur.soft}px)` }) : false, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, transition: reduced ? { duration: motionTokens.duration.fast } : { y: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter, delay: motionTokens.duration.instant }, filter: { duration: motionTokens.duration.standard, ease: enter, delay: motionTokens.duration.instant } }, children: [_jsxs(Trigger, { className: styles.trigger, ...(row.detail ? { type: "button", "data-timeline-trigger": "", "aria-expanded": expanded, "aria-controls": expanded ? detailId : undefined, onClick: onToggle } : {}), children: [_jsxs("span", { className: styles.text, children: [_jsxs("span", { className: styles.title, children: [row.actor && _jsx("span", { className: styles.actor, children: row.actor }), row.actor ? " " : "", row.title] }), row.meta && _jsx("span", { className: styles.meta, children: row.meta })] }), _jsxs("time", { className: styles.time, dateTime: new Date(row.time).toISOString(), title: timeFull, children: [_jsx(RiseText, { text: timeLabel, reduced: reduced }), _jsx("span", { className: styles.srOnly, children: timeFull })] }), row.detail && _jsx(motion.span, { className: styles.chevron, "aria-hidden": "true", initial: false, animate: { rotate: expanded ? 180 : 0 }, transition: reduced ? { duration: 0 } : motionTokens.spring.snappy, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) })] }), _jsx(AnimatePresence, { initial: false, children: expanded && row.detail && _jsx(motion.div, { id: detailId, className: styles.detail, initial: { height: 0 }, animate: { height: "auto" }, exit: { height: 0, transition: reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: motionTokens.spring.moderate.visualDuration } }, transition: reduced ? { duration: 0 } : motionTokens.spring.smooth, children: _jsx(motion.div, { className: styles.detailInner, initial: reduced ? { opacity: 0 } : { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant, ease: standard } }, transition: reduced ? { duration: motionTokens.duration.fast } : { duration: motionTokens.duration.standard, ease: enter, delay: motionTokens.duration.instant }, children: row.detail }) }, "detail") })] })] });
71
71
  }
72
72
  /** An event's time in epoch milliseconds (an unreadable time sorts as the oldest). */
73
73
  function timeOf(at) {
@@ -28,14 +28,14 @@
28
28
  .marker[data-tone="danger"] { background: color-mix(in oklab, var(--danger) 12%, var(--surface)); color: var(--danger); }
29
29
  .marker svg { width: 14px; height: 14px; stroke-width: 2; }
30
30
  /* The line runs from just under this marker to just above the next one and draws downward from its top. */
31
- .segment { position: absolute; top: calc(var(--marker-top) + var(--marker) + var(--line-gap)); bottom: calc(var(--line-gap) - var(--marker-top)); left: calc(var(--marker) / 2 - .5px); width: 1px; background: var(--border-strong); transform-origin: 50% 0; }
31
+ .segment { position: absolute; top: calc(var(--marker-top) + var(--marker) + var(--line-gap)); bottom: calc(var(--line-gap) - var(--marker-top)); left: calc(var(--marker) / 2 - var(--space-hair)); width: 1px; background: var(--border-strong); transform-origin: 50% 0; }
32
32
 
33
33
  .content { min-width: 0; }
34
- .trigger { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) auto; align-items: start; column-gap: var(--space-group); margin: 0; padding: 12px var(--space-group); border: 0; border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: transparent; color: inherit; font: inherit; text-align: left; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }
34
+ .trigger { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) auto; align-items: start; column-gap: var(--space-group); margin: 0; padding: var(--space-group) var(--space-group); border: 0; border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: transparent; color: inherit; font: inherit; text-align: left; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }
35
35
  button.trigger { grid-template-columns: minmax(0, 1fr) auto 16px; cursor: pointer; }
36
36
  @media (hover: hover) and (pointer: fine) { button.trigger:hover { background: var(--surface-muted); } }
37
37
  button.trigger:active { background: var(--surface-muted); }
38
- .text { display: grid; min-width: 0; gap: 2px; }
38
+ .text { display: grid; min-width: 0; gap: var(--space-hair); }
39
39
  .title { color: var(--text-secondary); font-size: var(--text-sm); line-height: 20px; }
40
40
  .actor { color: var(--foreground); font-weight: 500; }
41
41
  .meta { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
@@ -108,7 +108,7 @@ export default function Toast({ title, description, open = true, onOpenChange, d
108
108
  // A thrown toast keeps its release velocity; a closed one sinks back toward the edge it came from.
109
109
  // The throw settles loosely: it is invisible once the fade ends, so no long unseen tail keeps it mounted.
110
110
  exit: (to) => to
111
- ? { x: to, opacity: 0, transition: { x: { type: "spring", visualDuration: .3, bounce: 0, velocity: releaseVelocity.current, restDelta: 2, restSpeed: 40 }, opacity: { duration: motionTokens.duration.exit } } }
111
+ ? { x: to, opacity: 0, transition: { x: { type: "spring", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0, velocity: releaseVelocity.current, restDelta: 2, restSpeed: 40 }, opacity: { duration: motionTokens.duration.exit } } }
112
112
  : { opacity: 0, y: 8, scale: .97, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] } },
113
113
  };
114
114
  const swap = { initial: reduce ? { opacity: 0 } : textIn, animate: textShown, exit: reduce ? fadeOut : textOut, transition: reduce ? { duration: motionTokens.duration.instant } : enter };
@@ -3,8 +3,8 @@
3
3
  width: min(100%, 26rem);
4
4
  min-width: 0;
5
5
  align-items: flex-start;
6
- gap: 0.75rem;
7
- padding: 0.875rem 0.875rem 0.875rem 1rem;
6
+ gap: var(--space-group);
7
+ padding: var(--space-inset) var(--space-inset) var(--space-inset) var(--space-inset);
8
8
  border: 1px solid var(--border);
9
9
  border-radius: var(--radius-container); corner-shape: var(--corner-shape);
10
10
  background: color-mix(in oklab, var(--surface) 94%, var(--background));
@@ -35,9 +35,9 @@
35
35
  position: relative;
36
36
  display: grid;
37
37
  min-width: 0;
38
- gap: 0.125rem;
38
+ gap: var(--space-hair);
39
39
  /* Centers the title's first line on the 30px status icon. */
40
- padding-top: 0.3125rem;
40
+ padding-top: var(--space-tight);
41
41
  }
42
42
 
43
43
  .title {
@@ -69,7 +69,7 @@
69
69
  height: 2rem;
70
70
  flex: 0 0 auto;
71
71
  place-items: center;
72
- margin: -0.0625rem -0.125rem 0 0;
72
+ margin: calc(var(--space-hair) * -1) calc(var(--space-hair) * -1) 0 0;
73
73
  border: 0;
74
74
  border-radius: var(--radius-full); corner-shape: round;
75
75
  background: transparent;
@@ -282,7 +282,7 @@ function ToastItem({ toast, target, expanded, front, hidden, paused, reduce, sto
282
282
  if (!cancelled && offset > 0 && velocity > -200 && offset + project(velocity) > width * .4) {
283
283
  swiped.current = true;
284
284
  if (!reduce)
285
- animate(x, width + 40, { type: "spring", visualDuration: .32, bounce: 0, velocity: Math.max(velocity, 0) });
285
+ animate(x, width + 40, { type: "spring", visualDuration: motionTokens.spring.moderate.visualDuration, bounce: 0, velocity: Math.max(velocity, 0) });
286
286
  store.dismiss(id);
287
287
  return;
288
288
  }
@@ -1,5 +1,5 @@
1
1
  /* The region only holds the list; it never blocks clicks outside the toasts themselves. */
2
- .viewport { position: fixed; z-index: 60; bottom: var(--space-section); width: min(22.5rem, calc(100% - var(--space-block))); pointer-events: none; }
2
+ .viewport { position: fixed; z-index: var(--layer-toast); bottom: var(--space-section); width: min(22.5rem, calc(100% - var(--space-block))); pointer-events: none; }
3
3
  .bottom-right { right: var(--space-section); }
4
4
  .bottom-left { left: var(--space-section); }
5
5
  .bottom-center { right: 0; left: 0; margin-inline: auto; }
@@ -32,14 +32,14 @@
32
32
  .copy { position: relative; min-width: 0; flex: 1; font-size: var(--text-sm); letter-spacing: var(--tracking-body); line-height: 20px; }
33
33
  .title { position: relative; display: block; font-weight: 500; }
34
34
  .line { display: block; }
35
- .description { display: block; margin-top: 2px; color: var(--text-secondary); }
35
+ .description { display: block; margin-top: var(--space-hair); color: var(--text-secondary); }
36
36
 
37
37
  /* Clips only while its width folds, so the focus ring stays whole at rest. */
38
- .actionSlot { display: flex; flex: none; justify-content: flex-end; margin-top: -4px; }
38
+ .actionSlot { display: flex; flex: none; justify-content: flex-end; margin-top: calc(var(--space-tight) * -1); }
39
39
  .actionSlot[data-moving="true"] { overflow: hidden; }
40
40
  .action, .close { flex: none; border: 1px solid transparent; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: scale var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
41
41
  .action { height: 28px; padding: 0 var(--space-group); border-color: var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; white-space: nowrap; }
42
- .close { display: grid; width: 28px; height: 28px; margin: -4px -2px 0 0; place-items: center; border-radius: var(--radius-full); corner-shape: round; background: transparent; color: var(--text-muted); }
42
+ .close { display: grid; width: 28px; height: 28px; margin: calc(var(--space-tight) * -1) calc(var(--space-hair) * -1) 0 0; place-items: center; border-radius: var(--radius-full); corner-shape: round; background: transparent; color: var(--text-muted); }
43
43
  @media (hover: hover) and (pointer: fine) {
44
44
  .action:hover { border-color: var(--border-strong); background: var(--surface-muted); }
45
45
  .close:hover { background: var(--surface-muted); color: var(--foreground); }
@@ -49,7 +49,7 @@
49
49
  /* A narrow toast keeps its copy wide: the action drops under the copy instead of squeezing it. */
50
50
  @container (max-width: 21rem) {
51
51
  .content { flex-wrap: wrap; row-gap: var(--space-related); }
52
- .actionSlot { order: 1; flex-basis: 100%; justify-content: flex-start; margin-top: 0; padding-left: calc(18px + var(--space-group)); }
52
+ .actionSlot { order: 1; flex-basis: 100%; justify-content: flex-start; margin-top: 0; padding-left: calc(var(--space-comfort) + var(--space-group)); }
53
53
  }
54
54
  .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
55
55
 
@@ -1,4 +1,4 @@
1
- .tooltip { --tooltip-y: 3px; z-index: 90; max-width: 15rem; padding: var(--space-group) var(--space-inset); 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); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-body); transform-origin: var(--radix-tooltip-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-fast) var(--ease-enter); }
1
+ .tooltip { --tooltip-y: 3px; z-index: var(--layer-tooltip); max-width: 15rem; padding: var(--space-group) var(--space-inset); 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); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-body); transform-origin: var(--radix-tooltip-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-fast) var(--ease-enter); }
2
2
  .tooltip[data-side="bottom"] { --tooltip-y: -3px; }
3
3
  /* The first tooltip waits, then rises a few pixels from its trigger. Within the skip window the next one only fades.
4
4
  Transitions instead of keyframes, so returning to the trigger while it fades out reverses the fade instead of restarting it. */
@@ -4,6 +4,7 @@ import { useId, useRef, useState } from "react";
4
4
  import { ChevronRight, File, Folder, FolderOpen } from "lucide-react";
5
5
  import { AnimatePresence, motion, useIsPresent, useReducedMotion } from "motion/react";
6
6
  import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import { FluidHoverHighlight, useFluidHover } from "../../lib/fluid-hover.js";
7
8
  import styles from "./tree-view.module.css";
8
9
  /** A row is 36px plus the 2px gap above it; both collapse together, so nothing snaps when a row leaves. */
9
10
  const rowHeight = 38;
@@ -53,6 +54,9 @@ export function TreeView({ nodes, defaultExpandedIds = [], expandedIds, onExpand
53
54
  /** The folder that opened last; its new children fade in one after another, then everything else stays still. */
54
55
  const [openedId, setOpenedId] = useState(null);
55
56
  const refs = useRef(new Map());
57
+ const tree = useRef(null);
58
+ // Fluid hover: the nearest row is lit, gaps and padding included, and keyboard focus moves the fill without travel.
59
+ const hover = useFluidHover(tree, { items: '[role="treeitem"]' });
56
60
  const expanded = expandedIds ? new Set(expandedIds) : internalExpanded;
57
61
  const visible = flatten(nodes, expanded);
58
62
  const focusableId = visible.some(({ node }) => node.id === focusedId) ? focusedId : (visible[0]?.node.id ?? null);
@@ -131,17 +135,17 @@ export function TreeView({ nodes, defaultExpandedIds = [], expandedIds, onExpand
131
135
  toggle(node);
132
136
  }
133
137
  }
134
- return _jsx("div", { className: styles.tree, role: "tree", "aria-label": ariaLabel, "aria-multiselectable": "false", children: _jsx(AnimatePresence, { initial: false, mode: "sync", children: visible.map((item, index) => {
135
- const hasChildren = Boolean(item.node.children?.length);
136
- const isExpanded = hasChildren && expanded.has(item.node.id);
137
- const isSelected = selectedId === item.node.id;
138
- // Only rows that mount use this delay: the space opens in one motion and the new children follow in order.
139
- const delay = openedIndex >= 0 && index > openedIndex ? Math.min(index - openedIndex - 1, 7) * motionTokens.stagger.item : 0;
140
- return _jsx(motion.div, { className: styles.row, layout: reduced ? false : "position", initial: reduced ? false : { height: 0, opacity: 0, x: -6, overflow: "hidden" }, animate: { height: rowHeight, opacity: 1, x: 0, pointerEvents: "auto", transitionEnd: { overflow: "visible" }, transition: reduced ? still : { height: motionTokens.spring.smooth, opacity: { ...fadeIn, delay }, x: { ...motionTokens.spring.smooth, delay } } }, exit: reduced ? { opacity: 0, transition: still } : { height: 0, opacity: 0, x: -4, overflow: "hidden", pointerEvents: "none", transition: { height: motionTokens.spring.smooth, opacity: fadeOut, x: fadeOut } }, transition: reduced ? still : { layout: motionTokens.spring.smooth }, children: _jsxs("button", { ref: element => { if (element)
141
- refs.current.set(item.node.id, element);
142
- else
143
- refs.current.delete(item.node.id); }, type: "button", className: styles.item, role: "treeitem", "aria-level": item.depth, "aria-posinset": item.position, "aria-setsize": item.setSize, "aria-expanded": hasChildren ? isExpanded : undefined, "aria-selected": isSelected, tabIndex: focusableId === item.node.id ? 0 : -1, style: { "--tree-depth": item.depth }, onFocus: () => setFocusedId(item.node.id), onKeyDown: event => onKeyDown(event, item, index), onClick: () => { setFocusedId(item.node.id); select(item.node); if (hasChildren)
144
- toggle(item.node); }, children: [isSelected && _jsx(Selection, { layoutId: selectionLayoutId, reduced: reduced }), item.depth > 1 && _jsx(motion.span, { "aria-hidden": "true", className: styles.branch, initial: reduced ? false : { opacity: 0, scaleY: 0 }, animate: { opacity: 1, scaleY: 1 }, transition: reduced ? still : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay } }), _jsx(motion.span, { className: styles.disclosure, "aria-hidden": "true", initial: false, animate: { rotate: isExpanded ? 90 : 0 }, transition: reduced ? still : motionTokens.spring.snappy, children: hasChildren ? _jsx(ChevronRight, { size: 14, strokeWidth: 1.9 }) : null }), _jsx("span", { className: [styles.icon, hasChildren ? styles.folder : fileTone(item.node.label, styles)].join(" "), "aria-hidden": "true", children: item.node.icon ?? (hasChildren ? _jsx(FolderIcon, { open: isExpanded, reduced: reduced }) : _jsx(File, { size: 16, strokeWidth: 1.7 })) }), _jsx("span", { className: styles.label, children: item.node.label })] }) }, item.node.id);
145
- }) }) });
138
+ return _jsxs("div", { ref: tree, className: styles.tree, role: "tree", "aria-label": ariaLabel, "aria-multiselectable": "false", ...hover.handlers, children: [_jsx(FluidHoverHighlight, { hover: hover, className: styles.hover }), _jsx(AnimatePresence, { initial: false, mode: "sync", children: visible.map((item, index) => {
139
+ const hasChildren = Boolean(item.node.children?.length);
140
+ const isExpanded = hasChildren && expanded.has(item.node.id);
141
+ const isSelected = selectedId === item.node.id;
142
+ // Only rows that mount use this delay: the space opens in one motion and the new children follow in order.
143
+ const delay = openedIndex >= 0 && index > openedIndex ? Math.min(index - openedIndex - 1, 7) * motionTokens.stagger.item : 0;
144
+ return _jsx(motion.div, { className: styles.row, layout: reduced ? false : "position", initial: reduced ? false : { height: 0, opacity: 0, x: -6, overflow: "hidden" }, animate: { height: rowHeight, opacity: 1, x: 0, pointerEvents: "auto", transitionEnd: { overflow: "visible" }, transition: reduced ? still : { height: motionTokens.spring.smooth, opacity: { ...fadeIn, delay }, x: { ...motionTokens.spring.smooth, delay } } }, exit: reduced ? { opacity: 0, transition: still } : { height: 0, opacity: 0, x: -4, overflow: "hidden", pointerEvents: "none", transition: { height: motionTokens.spring.smooth, opacity: fadeOut, x: fadeOut } }, transition: reduced ? still : { layout: motionTokens.spring.smooth }, children: _jsxs("button", { ref: element => { if (element)
145
+ refs.current.set(item.node.id, element);
146
+ else
147
+ refs.current.delete(item.node.id); }, type: "button", className: styles.item, role: "treeitem", "aria-level": item.depth, "aria-posinset": item.position, "aria-setsize": item.setSize, "aria-expanded": hasChildren ? isExpanded : undefined, "aria-selected": isSelected, tabIndex: focusableId === item.node.id ? 0 : -1, style: { "--tree-depth": item.depth }, onFocus: () => setFocusedId(item.node.id), onKeyDown: event => onKeyDown(event, item, index), onClick: () => { setFocusedId(item.node.id); select(item.node); if (hasChildren)
148
+ toggle(item.node); }, children: [isSelected && _jsx(Selection, { layoutId: selectionLayoutId, reduced: reduced }), item.depth > 1 && _jsx(motion.span, { "aria-hidden": "true", className: styles.branch, initial: reduced ? false : { opacity: 0, scaleY: 0 }, animate: { opacity: 1, scaleY: 1 }, transition: reduced ? still : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay } }), _jsx(motion.span, { className: styles.disclosure, "aria-hidden": "true", initial: false, animate: { rotate: isExpanded ? 90 : 0 }, transition: reduced ? still : motionTokens.spring.snappy, children: hasChildren ? _jsx(ChevronRight, { size: 14, strokeWidth: 1.9 }) : null }), _jsx("span", { className: [styles.icon, hasChildren ? styles.folder : fileTone(item.node.label, styles)].join(" "), "aria-hidden": "true", children: item.node.icon ?? (hasChildren ? _jsx(FolderIcon, { open: isExpanded, reduced: reduced }) : _jsx(File, { size: 16, strokeWidth: 1.7 })) }), _jsx("span", { className: styles.label, children: item.node.label })] }) }, item.node.id);
149
+ }) })] });
146
150
  }
147
151
  export default TreeView;
@@ -1,22 +1,24 @@
1
- .tree { display: block; width: 100%; min-width: 0; box-sizing: border-box; padding: 6px 8px 8px; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); overflow: hidden; }
1
+ .tree { position: relative; display: block; width: 100%; min-width: 0; box-sizing: border-box; padding: var(--space-related) var(--space-related) var(--space-related); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); overflow: hidden; }
2
2
  /* Rows clip only while their height animates (Motion sets overflow), so the selection can glide between them unclipped. */
3
3
  .row { position: relative; display: flow-root; min-width: 0; height: 38px; }
4
- .item { position: relative; display: flex; width: 100%; min-width: 0; height: 36px; margin-top: 2px; align-items: center; gap: 8px; padding: 0 10px; padding-left: calc(10px + (var(--tree-depth) - 1) * 18px); border: 0; border-radius: var(--radius-detail); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); line-height: 1; text-align: left; cursor: pointer; outline: none; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
4
+ .item { position: relative; display: flex; width: 100%; min-width: 0; height: 36px; margin-top: var(--space-hair); align-items: center; gap: var(--space-related); padding: 0 var(--space-group); padding-left: calc(var(--space-group) + (var(--tree-depth) - 1) * var(--space-comfort)); border: 0; border-radius: var(--radius-detail); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); line-height: 1; text-align: left; cursor: pointer; outline: none; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
5
5
  /* Selection changes color only; a heavier weight would reflow the label. */
6
6
  .item[aria-selected="true"] { color: var(--foreground); }
7
+ /* One hover fill glides between rows, under the selection and the row content. */
8
+ .hover { border-radius: var(--radius-detail); background: var(--surface-muted); }
7
9
  .selection { position: absolute; inset: 0; z-index: 0; border-radius: var(--radius-detail); background: var(--surface-muted); }
8
- .branch { position: absolute; top: 4px; bottom: 4px; left: calc(16.5px + (var(--tree-depth) - 2) * 18px); z-index: 1; width: 1px; background: var(--border); transform-origin: top; pointer-events: none; }
10
+ .branch { position: absolute; top: var(--space-tight); bottom: var(--space-tight); left: calc(var(--space-inset) + (var(--tree-depth) - 2) * var(--space-comfort)); z-index: 1; width: 1px; background: var(--border); transform-origin: top; pointer-events: none; }
9
11
  .disclosure, .icon, .label { position: relative; z-index: 1; }
10
12
  .disclosure { display: grid; width: 14px; height: 16px; flex: 0 0 14px; place-items: center; color: var(--text-muted); }
11
13
  .icon { display: grid; width: 16px; height: 16px; flex: 0 0 16px; place-items: center; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); }
12
14
  .folder { color: var(--text-secondary); }
13
15
  .item[aria-expanded="true"] .folder, .item[aria-selected="true"] .folder { color: var(--foreground); }
14
16
  .folderGlyph { position: absolute; inset: 0; display: grid; place-items: center; }
15
- .typescript { color: color-mix(in oklab, #3178c6 78%, var(--foreground)); }
16
- .stylesheet { color: color-mix(in oklab, #8b6ace 73%, var(--foreground)); }
17
- .markdown { color: color-mix(in oklab, #38896b 74%, var(--foreground)); }
18
- .json { color: color-mix(in oklab, #c48637 78%, var(--foreground)); }
17
+ .typescript { color: color-mix(in oklab, var(--file-typescript) 78%, var(--foreground)); }
18
+ .stylesheet { color: color-mix(in oklab, var(--file-stylesheet) 73%, var(--foreground)); }
19
+ .markdown { color: color-mix(in oklab, var(--file-markdown) 74%, var(--foreground)); }
20
+ .json { color: color-mix(in oklab, var(--file-json) 78%, var(--foreground)); }
19
21
  /* Its own line height: the row sets 1, and the clipped box (needed for the ellipsis) would cut descenders like g. */
20
22
  .label { min-width: 0; overflow: hidden; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
21
- @media (hover: hover) and (pointer: fine) { .item:hover { background: var(--surface-muted); color: var(--foreground); } }
23
+ @media (hover: hover) and (pointer: fine) { .item:hover { color: var(--foreground); } }
22
24
  @media (prefers-reduced-motion: reduce) { .item, .icon { transition: none; } }
@@ -5,32 +5,32 @@
5
5
  /* Breadcrumb on the left, the total of what fills the view on the right. */
6
6
  .bar { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--space-group); }
7
7
  /* Pull the trail back by the crumb padding, so the first label lines up with the tiles and the title. */
8
- .crumbs { min-width: 0; margin-left: -4px; }
8
+ .crumbs { min-width: 0; margin-left: calc(var(--space-tight) * -1); }
9
9
  .crumbList { position: relative; display: flex; min-width: 0; min-height: 32px; flex-wrap: wrap; align-items: center; margin: 0; padding: 0; list-style: none; }
10
10
  .crumb { display: inline-flex; align-items: center; min-width: 0; }
11
- .crumbSep { padding: 0 4px; color: var(--border-strong); font-size: var(--text-sm); }
12
- .crumbButton { min-height: 32px; padding: 0 6px; margin: 0 -2px; border: 0; border-radius: var(--radius-detail); background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); white-space: nowrap; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
11
+ .crumbSep { padding: 0 var(--space-tight); color: var(--border-strong); font-size: var(--text-sm); }
12
+ .crumbButton { min-height: 32px; padding: 0 var(--space-related); margin: 0 calc(var(--space-hair) * -1); border: 0; border-radius: var(--radius-detail); background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); white-space: nowrap; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
13
13
  .crumbButton:active { transform: scale(.97); }
14
- .crumbCurrent { padding: 0 4px; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; }
14
+ .crumbCurrent { padding: 0 var(--space-tight); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; }
15
15
  @media (hover: hover) and (pointer: fine) { .crumbButton:hover { background: color-mix(in oklab, var(--foreground) 5%, transparent); color: var(--foreground); } }
16
16
  .total { position: relative; display: inline-grid; flex: none; justify-items: end; overflow: hidden; margin: 0; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
17
17
  .totalValue { display: block; }
18
18
 
19
19
  /* Tiles are absolutely placed and sized each frame, so text never scales while a tile grows or shrinks. */
20
20
  .stage { position: relative; min-width: 0; overflow: hidden; border-radius: var(--radius-control); outline: none; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
21
- .tile { position: absolute; top: 0; left: 0; display: flex; flex-direction: column; gap: 1px; overflow: hidden; padding: 6px 8px; border-radius: var(--radius-detail); background: var(--fill); color: var(--foreground); cursor: pointer; visibility: hidden; will-change: transform, width, height; transition: background-color var(--duration-considered) var(--ease-standard), color var(--duration-considered) var(--ease-standard), filter var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
22
- .tile[data-branch][data-level="1"] { padding: 3px 8px; border-radius: var(--radius-detail); background: color-mix(in oklab, var(--hue) 9%, var(--surface)); color: var(--foreground); cursor: zoom-in; }
21
+ .tile { position: absolute; top: 0; left: 0; display: flex; flex-direction: column; gap: 1px; overflow: hidden; padding: var(--space-related) var(--space-related); border-radius: var(--radius-detail); background: var(--fill); color: var(--foreground); cursor: pointer; visibility: hidden; will-change: transform, width, height; transition: background-color var(--duration-considered) var(--ease-standard), color var(--duration-considered) var(--ease-standard), filter var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
22
+ .tile[data-branch][data-level="1"] { padding: var(--space-tight) var(--space-related); border-radius: var(--radius-detail); background: color-mix(in oklab, var(--hue) 9%, var(--surface)); color: var(--foreground); cursor: zoom-in; }
23
23
  .tileLabel, .tileValue { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-xs); line-height: 16px; opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard); }
24
24
  .tileLabel { font-weight: 500; }
25
25
  .tileValue { opacity: 0; font-variant-numeric: tabular-nums; }
26
26
  .tile[data-room="name"] .tileLabel, .tile[data-room="full"] .tileLabel, .tile[data-room="full"] .tileValue { opacity: 1; }
27
27
  .tile[data-room="full"] .tileValue { opacity: .72; }
28
28
  /* A branch on the top level prints its name and total on one header line. */
29
- .tile[data-branch][data-level="1"] { flex-direction: row; align-items: baseline; justify-content: space-between; gap: 8px; }
29
+ .tile[data-branch][data-level="1"] { flex-direction: row; align-items: baseline; justify-content: space-between; gap: var(--space-related); }
30
30
  .tile[data-branch][data-level="1"] .tileValue { flex: none; opacity: .6; }
31
31
  .tile[data-branch][data-level="1"][data-room="none"] .tileLabel, .tile[data-branch][data-level="1"][data-room="none"] .tileValue { opacity: 0; }
32
32
  .tile[data-branch][data-level="1"][data-room="name"] .tileValue { opacity: .6; }
33
- .tile[data-level="2"] { padding: 5px 7px; }
33
+ .tile[data-level="2"] { padding: var(--space-tight) var(--space-related); }
34
34
 
35
35
  .tile[data-active] { box-shadow: inset 0 0 0 2px var(--foreground); }
36
36
  .tile[data-lineage] { box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--hue) 70%, transparent); }
@@ -38,18 +38,18 @@
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
- .tooltip { position: absolute; top: 0; left: 0; z-index: 50; display: grid; min-width: 160px; max-width: min(240px, 100%); grid-template-columns: minmax(0, 1fr); gap: 2px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-control); 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); }
41
+ .tooltip { position: absolute; top: 0; left: 0; z-index: var(--layer-tooltip); display: grid; min-width: 160px; max-width: min(240px, 100%); grid-template-columns: minmax(0, 1fr); gap: var(--space-hair); padding: var(--space-group) var(--space-group); border: 1px solid var(--border); border-radius: var(--radius-control); 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); }
42
42
  .stage[data-active] .tooltip { opacity: 1; scale: 1; }
43
43
  .tipTitle { margin: 0; overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); text-overflow: ellipsis; white-space: nowrap; }
44
- .tipValue { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0; font-size: var(--text-sm); line-height: 1.35; }
44
+ .tipValue { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-group); margin: 0; font-size: var(--text-sm); line-height: 1.35; }
45
45
  .tipName { min-width: 0; overflow: hidden; color: var(--foreground); text-overflow: ellipsis; white-space: nowrap; }
46
46
  .tipAmount { flex: none; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }
47
47
  .tipNote { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
48
48
 
49
49
  /* The color scale: stepped swatches from light to dark, never a gradient. */
50
- .scale { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
51
- .scaleLabel { margin-right: 4px; color: var(--text-secondary); }
52
- .steps { display: inline-flex; gap: 2px; }
50
+ .scale { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: var(--space-related); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
51
+ .scaleLabel { margin-right: var(--space-tight); color: var(--text-secondary); }
52
+ .steps { display: inline-flex; gap: var(--space-hair); }
53
53
  .step { width: 18px; height: 10px; border-radius: var(--radius-detail); background: var(--fill); }
54
54
 
55
55
  .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@@ -9,7 +9,7 @@ const { spring, duration, ease, blur } = motionTokens;
9
9
  /** Motion drops inherited velocity on time-defined springs, so values that retarget mid-flight run the same springs written as stiffness and damping. */
10
10
  const physical = ({ visualDuration, bounce }, restDelta = .001) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };
11
11
  const settle = physical(spring.smooth);
12
- const reveal = physical({ visualDuration: duration.considered + .1, bounce: 0 });
12
+ const reveal = physical({ visualDuration: duration.expressive + .1, bounce: 0 });
13
13
  const turn = physical(spring.smooth);
14
14
  const fit = physical(spring.morph, .01);
15
15
  /** A segment stops two pixels short of the next, so neighbours read apart without an outline. */