@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.
- package/README.md +4 -3
- package/check.mjs +14 -5
- package/components/activity-heatmap/activity-heatmap.module.css +4 -4
- package/components/alert/alert.module.css +1 -1
- package/components/animated-counter/animated-counter.js +1 -1
- package/components/announcement-bar/announcement-bar.module.css +6 -6
- package/components/avatar/avatar.d.ts +2 -2
- package/components/avatar/avatar.js +1 -1
- package/components/avatar/avatar.module.css +5 -2
- package/components/avatar-group/avatar-group.module.css +4 -4
- package/components/badge/badge.module.css +4 -4
- package/components/bar-chart/bar-chart.js +1 -1
- package/components/bar-chart/bar-chart.module.css +4 -4
- package/components/billing-toggle/billing-toggle.js +2 -2
- package/components/billing-toggle/billing-toggle.module.css +10 -10
- package/components/bottom-sheet/bottom-sheet.js +1 -1
- package/components/bottom-sheet/bottom-sheet.module.css +4 -5
- package/components/breadcrumb/breadcrumb.d.ts +8 -2
- package/components/breadcrumb/breadcrumb.js +21 -9
- package/components/breadcrumb/breadcrumb.module.css +7 -3
- package/components/brush-chart/brush-chart.module.css +9 -9
- package/components/button/button.d.ts +7 -2
- package/components/button/button.js +3 -1
- package/components/button/button.module.css +10 -1
- package/components/button-group/button-group.module.css +7 -7
- package/components/calendar/calendar.js +15 -5
- package/components/calendar/calendar.module.css +13 -10
- package/components/card/card.js +4 -4
- package/components/card/card.module.css +2 -2
- package/components/card-stack/card-stack.js +2 -2
- package/components/card-stack/card-stack.module.css +2 -3
- package/components/carousel/carousel.module.css +3 -3
- package/components/chat-thread/chat-thread.module.css +41 -41
- package/components/checkbox/checkbox.module.css +1 -1
- package/components/chip-group/chip-group.js +1 -1
- package/components/chip-group/chip-group.module.css +2 -2
- package/components/code-block/code-block.module.css +7 -14
- package/components/color-picker/color-picker.module.css +20 -21
- package/components/combobox/combobox.js +5 -1
- package/components/combobox/combobox.module.css +2 -2
- package/components/command-palette/command-palette.js +18 -11
- package/components/command-palette/command-palette.module.css +18 -18
- package/components/comment-thread/comment-thread.module.css +35 -35
- package/components/confirm-morph/confirm-morph.js +5 -5
- package/components/confirm-morph/confirm-morph.module.css +9 -9
- package/components/context-menu/context-menu.js +10 -28
- package/components/context-menu/context-menu.module.css +3 -3
- package/components/copy-button/copy-button.js +4 -4
- package/components/countdown/countdown.module.css +4 -4
- package/components/date-picker/date-picker.js +3 -3
- package/components/date-picker/date-picker.module.css +7 -7
- package/components/date-range-picker/date-range-picker.js +13 -10
- package/components/date-range-picker/date-range-picker.module.css +23 -22
- package/components/dialog/dialog.module.css +2 -2
- package/components/donut-chart/donut-chart.module.css +5 -5
- package/components/drawer/drawer.module.css +7 -7
- package/components/dropdown-menu/dropdown-menu.d.ts +71 -1
- package/components/dropdown-menu/dropdown-menu.js +100 -6
- package/components/dropdown-menu/dropdown-menu.module.css +15 -5
- package/components/elastic-slider/elastic-slider.js +2 -2
- package/components/elastic-slider/elastic-slider.module.css +4 -4
- package/components/expandable-card/expandable-card.js +1 -1
- package/components/expandable-card/expandable-card.module.css +1 -1
- package/components/expanding-button-group/expanding-button-group.module.css +3 -3
- package/components/expanding-search/expanding-search.js +3 -3
- package/components/expanding-search/expanding-search.module.css +9 -9
- package/components/file-dropzone/file-dropzone.js +3 -3
- package/components/file-dropzone/file-dropzone.module.css +21 -21
- package/components/file-upload/file-upload.module.css +7 -7
- package/components/filter-toolbar/filter-toolbar.js +7 -3
- package/components/filter-toolbar/filter-toolbar.module.css +9 -9
- package/components/floating-button-group/floating-button-group.module.css +7 -7
- package/components/gauge/gauge.js +1 -1
- package/components/gauge/gauge.module.css +2 -2
- package/components/hover-card/hover-card.js +2 -2
- package/components/hover-card/hover-card.module.css +2 -2
- package/components/image-compare/image-compare.module.css +4 -4
- package/components/inline-edit/inline-edit.module.css +12 -12
- package/components/json-viewer/json-viewer.module.css +12 -12
- package/components/line-chart/line-chart.js +1 -1
- package/components/line-chart/line-chart.module.css +8 -8
- package/components/mention-input/mention-input.js +4 -2
- package/components/mention-input/mention-input.module.css +8 -8
- package/components/metric-card/metric-card.module.css +1 -1
- package/components/money-input/money-input.js +6 -3
- package/components/money-input/money-input.module.css +10 -10
- package/components/morph-select/morph-select.js +7 -5
- package/components/morph-select/morph-select.module.css +13 -13
- package/components/multi-select/multi-select.js +4 -2
- package/components/multi-select/multi-select.module.css +6 -6
- package/components/nav-list/nav-list.d.ts +64 -0
- package/components/nav-list/nav-list.js +96 -0
- package/components/nav-list/nav-list.module.css +25 -0
- package/components/notification-center/notification-center.module.css +27 -27
- package/components/number-field/number-field.js +3 -3
- package/components/number-field/number-field.module.css +3 -3
- package/components/otp-input/otp-input.module.css +4 -4
- package/components/pagination/pagination.module.css +1 -1
- package/components/password-field/password-field.module.css +2 -2
- package/components/password-strength/password-strength.module.css +8 -8
- package/components/phone-input/phone-input.js +9 -7
- package/components/phone-input/phone-input.module.css +19 -19
- package/components/popover/popover.module.css +2 -2
- package/components/progress/progress.module.css +1 -1
- package/components/radio-cards/radio-cards.module.css +8 -8
- package/components/radio-group/radio-group.js +4 -1
- package/components/radio-group/radio-group.module.css +4 -3
- package/components/resizable-panels/resizable-panels.module.css +5 -5
- package/components/rich-text-editor/rich-text-editor.js +3 -3
- package/components/rich-text-editor/rich-text-editor.module.css +12 -12
- package/components/ridgeline/ridgeline.module.css +7 -7
- package/components/scroll-area/scroll-area.module.css +8 -8
- package/components/segmented-control/segmented-control.d.ts +3 -1
- package/components/segmented-control/segmented-control.js +5 -2
- package/components/segmented-control/segmented-control.module.css +9 -4
- package/components/select/select.module.css +5 -5
- package/components/shortcut-recorder/shortcut-recorder.js +5 -5
- package/components/shortcut-recorder/shortcut-recorder.module.css +16 -16
- package/components/signature-pad/signature-pad.module.css +11 -11
- package/components/slider/slider.module.css +4 -4
- package/components/slope-chart/slope-chart.module.css +7 -7
- package/components/slot-text/slot-text.js +2 -1
- package/components/sortable-data-table/sortable-data-table.module.css +4 -4
- package/components/sparkline/sparkline.js +1 -1
- package/components/split-button/split-button.module.css +6 -6
- package/components/stepper/stepper.module.css +3 -3
- package/components/streamgraph/streamgraph.module.css +7 -7
- package/components/swipe-actions/swipe-actions.js +2 -2
- package/components/swipe-actions/swipe-actions.module.css +5 -5
- package/components/switch/switch.js +2 -2
- package/components/switch/switch.module.css +1 -1
- package/components/tabs/tabs.d.ts +9 -2
- package/components/tabs/tabs.js +11 -5
- package/components/tabs/tabs.module.css +10 -3
- package/components/tag-input/tag-input.module.css +4 -4
- package/components/text-reveal/text-reveal.js +1 -1
- package/components/time-picker/time-picker.js +4 -2
- package/components/time-picker/time-picker.module.css +3 -3
- package/components/timeline/timeline.js +4 -4
- package/components/timeline/timeline.module.css +3 -3
- package/components/toast/toast.js +1 -1
- package/components/toast/toast.module.css +5 -5
- package/components/toast-stack/toast-stack.js +1 -1
- package/components/toast-stack/toast-stack.module.css +5 -5
- package/components/tooltip/tooltip.module.css +1 -1
- package/components/tree-view/tree-view.js +16 -12
- package/components/tree-view/tree-view.module.css +10 -8
- package/components/treemap/treemap.module.css +13 -13
- package/components/usage-meter/usage-meter.js +1 -1
- package/components/usage-meter/usage-meter.module.css +5 -5
- package/components/user-menu/user-menu.js +16 -14
- package/components/user-menu/user-menu.module.css +22 -22
- package/components/waffle-chart/waffle-chart.module.css +7 -7
- package/components.json +416 -1
- package/eslint.js +35 -3
- package/lib/fluid-hover.d.ts +83 -0
- package/lib/fluid-hover.js +184 -0
- package/lib/menu-highlight.d.ts +24 -11
- package/lib/menu-highlight.js +29 -28
- package/lib/motion-tokens.d.ts +76 -18
- package/lib/motion-tokens.js +45 -10
- package/package.json +1 -1
- 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
|
|
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 {};
|
package/components/tabs/tabs.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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: .
|
|
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) -
|
|
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:
|
|
11
|
-
.tag { position: relative; display: inline-flex; align-items: center; gap:
|
|
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
|
|
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
|
|
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.
|
|
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,
|
|
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:
|
|
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:
|
|
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) -
|
|
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: .
|
|
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: .
|
|
67
|
-
const draw = reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: .
|
|
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: .
|
|
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 -
|
|
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:
|
|
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:
|
|
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: .
|
|
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:
|
|
7
|
-
padding:
|
|
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:
|
|
38
|
+
gap: var(--space-hair);
|
|
39
39
|
/* Centers the title's first line on the 30px status icon. */
|
|
40
|
-
padding-top:
|
|
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: -
|
|
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: .
|
|
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:
|
|
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:
|
|
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: -
|
|
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: -
|
|
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(
|
|
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:
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
16
|
-
.stylesheet { color: color-mix(in oklab,
|
|
17
|
-
.markdown { color: color-mix(in oklab,
|
|
18
|
-
.json { color: color-mix(in oklab,
|
|
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 {
|
|
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: -
|
|
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
|
|
12
|
-
.crumbButton { min-height: 32px; padding: 0
|
|
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
|
|
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:
|
|
22
|
-
.tile[data-branch][data-level="1"] { padding:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
51
|
-
.scaleLabel { margin-right:
|
|
52
|
-
.steps { display: inline-flex; gap:
|
|
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.
|
|
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. */
|