@robr0/design-system 0.14.0 → 0.16.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 (88) hide show
  1. package/README.md +11 -8
  2. package/behaviors/focusable.d.ts +26 -0
  3. package/behaviors/focusable.js +18 -0
  4. package/behaviors/useFocusScope.d.ts +38 -0
  5. package/behaviors/useFocusScope.js +82 -0
  6. package/behaviors/useLayer.d.ts +43 -0
  7. package/behaviors/useLayer.js +55 -0
  8. package/behaviors/useMounted.d.ts +7 -0
  9. package/behaviors/useMounted.js +14 -0
  10. package/behaviors/useScrollLock.d.ts +55 -0
  11. package/behaviors/useScrollLock.js +59 -0
  12. package/charts.d.ts +1 -0
  13. package/charts.js +2 -0
  14. package/components/AgentPlan/AgentPlan.css +12 -3
  15. package/components/AiButton/AiButton.css +327 -0
  16. package/components/AiButton/AiButton.d.ts +42 -0
  17. package/components/AiButton/AiButton.js +140 -16
  18. package/components/AlertDialog/AlertDialog.js +13 -58
  19. package/components/AnchorNav/AnchorNav.css +74 -0
  20. package/components/AnchorNav/AnchorNav.d.ts +7 -0
  21. package/components/AnchorNav/AnchorNav.js +74 -21
  22. package/components/Button/Button.css +54 -0
  23. package/components/Button/Button.d.ts +2 -2
  24. package/components/Chart/Chart.css +4 -1
  25. package/components/CircularButton/CircularButton.css +46 -0
  26. package/components/CircularButton/CircularButton.d.ts +13 -2
  27. package/components/CircularButton/CircularButton.js +19 -16
  28. package/components/Combobox/Combobox.js +2 -1
  29. package/components/CommandPalette/CommandPalette.css +36 -8
  30. package/components/CommandPalette/CommandPalette.js +9 -54
  31. package/components/Composer/Composer.css +72 -0
  32. package/components/Composer/Composer.d.ts +25 -6
  33. package/components/Composer/Composer.js +8 -0
  34. package/components/ContributionGraph/ContributionGraph.css +21 -0
  35. package/components/ContributionGraph/ContributionGraph.js +18 -10
  36. package/components/Dialog/Dialog.js +9 -62
  37. package/components/Drawer/Drawer.js +9 -62
  38. package/components/Dropdown/Dropdown.js +2 -1
  39. package/components/DropdownMenu/DropdownMenu.js +11 -3
  40. package/components/Figure/Figure.css +14 -4
  41. package/components/Figure/Figure.js +11 -20
  42. package/components/FunnelChart/FunnelChart.css +11 -36
  43. package/components/FunnelChart/FunnelChart.d.ts +19 -16
  44. package/components/FunnelChart/FunnelChart.js +67 -39
  45. package/components/Gauge/Gauge.css +11 -1
  46. package/components/Meter/Meter.css +10 -1
  47. package/components/ModelPicker/ModelPicker.js +2 -1
  48. package/components/Popover/Popover.js +3 -0
  49. package/components/ProgressBar/ProgressBar.css +1 -1
  50. package/components/SegmentedControl/SegmentedControl.css +27 -2
  51. package/components/SegmentedControl/SegmentedControl.d.ts +3 -1
  52. package/components/SegmentedControl/SegmentedControl.js +3 -1
  53. package/components/Slider/Slider.css +1 -1
  54. package/components/Slider/Slider.js +1 -1
  55. package/components/SourceTrail/SourceTrail.css +209 -0
  56. package/components/SourceTrail/SourceTrail.d.ts +49 -0
  57. package/components/SourceTrail/SourceTrail.js +108 -0
  58. package/components/Sparkline/Sparkline.css +26 -2
  59. package/components/Sparkline/Sparkline.js +2 -1
  60. package/components/Spinner/Spinner.css +1 -1
  61. package/components/SplitButton/SplitButton.d.ts +1 -1
  62. package/components/SplitButton/SplitButton.js +1 -0
  63. package/components/TimePicker/TimePicker.js +2 -1
  64. package/components/ToggleGroup/ToggleGroup.css +17 -0
  65. package/components/ToggleGroup/ToggleGroup.d.ts +2 -0
  66. package/components/ToggleGroup/ToggleGroup.js +2 -0
  67. package/components/ToolCall/ToolCall.css +16 -3
  68. package/components/Tooltip/Tooltip.css +24 -7
  69. package/components/Tooltip/Tooltip.d.ts +2 -1
  70. package/components/Tooltip/Tooltip.js +6 -0
  71. package/components/UsageCard/UsageCard.css +30 -0
  72. package/components/UsageCard/UsageCard.d.ts +48 -0
  73. package/components/UsageCard/UsageCard.js +54 -0
  74. package/components/registry.d.ts +8 -2
  75. package/components/registry.json +43 -16
  76. package/components/registry.json.d.ts +43 -16
  77. package/components/registry.json.js +1 -1
  78. package/index.d.ts +2 -1
  79. package/index.js +4 -2
  80. package/package.json +5 -1
  81. package/tokens/motion.d.ts +10 -4
  82. package/tokens/motion.js +7 -1
  83. package/tokens/registry.json +10 -0
  84. package/tokens/registry.json.d.ts +10 -0
  85. package/tokens/registry.json.js +1 -1
  86. package/tokens/tokens-dark.css +22 -2
  87. package/tokens/tokens-light.css +38 -11
  88. package/tokens/tokens-primitives.css +3 -0
package/README.md CHANGED
@@ -18,7 +18,7 @@ The **live site** is the portfolio built on the design system. **Storybook** is
18
18
  | Part | Description |
19
19
  |---|---|
20
20
  | **Portfolio website** (`/website`) | Next.js app with case studies, work history, writing, and about pages, all built exclusively with the design system components below. **[Live site →](https://robertritacca.com/)** |
21
- | **Design system** (`/src`) | <!-- component-count -->120<!-- /component-count --> React components, a three-tier token architecture, dark mode, a WebGL2 ambient background that themes itself from your tokens, and a full documentation site. Built to production standards. **[Storybook →](https://design-system-iota-one.vercel.app/?path=/docs/robr0-ds--docs)** |
21
+ | **Design system** (`/src`) | <!-- component-count -->122<!-- /component-count --> React components, a three-tier token architecture, dark mode, a WebGL2 ambient background that themes itself from your tokens, and a full documentation site. Built to production standards. **[Storybook →](https://design-system-iota-one.vercel.app/?path=/docs/robr0-ds--docs)** |
22
22
  | **AI layer** (`ai` components in `/src`, chat in `/website`, `/evals`) | A site-wide chat that answers questions about the work: built from the library's own `ai` components, grounded in a corpus generated from the site's published content, and scored by a golden-set eval. An MCP endpoint serves the same public data, plus the component prop API, to any agent. |
23
23
 
24
24
  ---
@@ -28,7 +28,7 @@ The **live site** is the portfolio built on the design system. **Storybook** is
28
28
  ### Components
29
29
 
30
30
  <!-- component-list:start -->
31
- Accordion · Agent plan · Agent status · AI button · Alert · Alert dialog · Anchor nav · App layout · App sidebar · Area chart · Avatar · Avatar group · Badge · Banner · Bar chart · Breadcrumb · Button · Button group · Card · Card stack · Carousel · Chat header · Chat marker · Chat message · Chat thread · Checkbox · Chip · Circular button · Code block · Code diff · Colour picker · Combo chart · Combobox · Command palette · Composer · Contact card · Context menu · Contribution graph · Data table · Date input · Date picker · Dialog · Divider · Document chip · Drawer · Dropdown · Dropdown menu · Empty state · Entity card · Event calendar · Field · Figure · File input · Filter bar · Funnel chart · Gauge · Globe · Hover card · Image compare · Input · Instructions · Interrupt card · Kbd · Legend tile · Line chart · Link list · Map callout · Map legend · Message actions · Message card · Meter · Model picker · Nav · Nav list · Notification centre · Number input · Pagination · Panel · Pie chart · Pin input · Popover · Progress bar · Prompt suggestions · Prose · Quote · Radar chart · Radial chart · Radio button · Rating · Reasoning · Scatter chart · Section title · Segmented control · Selection card · Shader field · Skeleton · Slider · Source chip · Sparkline · Spinner · Split button · Split pane · Stacked bar chart · Stat · Stepper · Streaming text · Swatch · Table · Tabs · Tag input · Textarea · Time picker · Timeline · Toast · Toggle group · Toggle switch · Tool call · Tooltip · Tree view · Treemap
31
+ Accordion · Agent plan · Agent status · AI button · Alert · Alert dialog · Anchor nav · App layout · App sidebar · Area chart · Avatar · Avatar group · Badge · Banner · Bar chart · Breadcrumb · Button · Button group · Card · Card stack · Carousel · Chat header · Chat marker · Chat message · Chat thread · Checkbox · Chip · Circular button · Code block · Code diff · Colour picker · Combo chart · Combobox · Command palette · Composer · Contact card · Context menu · Contribution graph · Data table · Date input · Date picker · Dialog · Divider · Document chip · Drawer · Dropdown · Dropdown menu · Empty state · Entity card · Event calendar · Field · Figure · File input · Filter bar · Funnel chart · Gauge · Globe · Hover card · Image compare · Input · Instructions · Interrupt card · Kbd · Legend tile · Line chart · Link list · Map callout · Map legend · Message actions · Message card · Meter · Model picker · Nav · Nav list · Notification centre · Number input · Pagination · Panel · Pie chart · Pin input · Popover · Progress bar · Prompt suggestions · Prose · Quote · Radar chart · Radial chart · Radio button · Rating · Reasoning · Scatter chart · Section title · Segmented control · Selection card · Shader field · Skeleton · Slider · Source chip · Source trail · Sparkline · Spinner · Split button · Split pane · Stacked bar chart · Stat · Stepper · Streaming text · Swatch · Table · Tabs · Tag input · Textarea · Time picker · Timeline · Toast · Toggle group · Toggle switch · Tool call · Tooltip · Tree view · Treemap · Usage card
32
32
  <!-- component-list:end -->
33
33
 
34
34
  ### Ambient background
@@ -71,7 +71,7 @@ import '@robr0/design-system/tokens/tokens.css';
71
71
  import { Button, Card, Badge } from '@robr0/design-system';
72
72
  ```
73
73
 
74
- Deep imports work too (`@robr0/design-system/components/Button/Button`). The Recharts-backed charts live behind a separate entry so the optional `recharts` peer dependency is only needed if you use them; Sparkline and ContributionGraph are dependency-free and export from the main barrel:
74
+ Deep imports work too (`@robr0/design-system/components/Button/Button`). The Recharts-backed charts live behind a separate entry so the optional `recharts` peer dependency is only needed if you use them; the dependency-free charts export from the main barrel with everything else:
75
75
 
76
76
  ```tsx
77
77
  import { BarChart, LineChart } from '@robr0/design-system/charts';
@@ -84,7 +84,7 @@ import { BarChart, LineChart } from '@robr0/design-system/charts';
84
84
  ```css
85
85
  :root { --font-family-primary: 'Inter', sans-serif; }
86
86
  ```
87
- - **Colours, radius, spacing**: every semantic token chains to a primitive, so overriding a primitive re-themes everything built on it. The action colour is theme-split by design (light fills run teal-08/09/10, dark inverts to teal-05/04/03), so rebranding it means re-keying those steps:
87
+ - **Colours, radius, spacing**: every semantic token chains to a primitive, so overriding a primitive re-themes everything built on it. The action colour is theme-split by design (light fills run teal-08/09/10, dark inverts to teal-05/04/03), and the six fill steps below are the core of a rebrand:
88
88
  ```css
89
89
  :root {
90
90
  --primitive-teal-08: #6D31D3; /* light fill */
@@ -96,6 +96,7 @@ import { BarChart, LineChart } from '@robr0/design-system/charts';
96
96
  --primitive-radius-full: 12px; /* pill buttons become rounded rectangles */
97
97
  }
98
98
  ```
99
+ A complete rebrand re-keys the whole teal ramp (01-10): the remaining steps carry the primary label, active icons, hover/selected input borders, and the AI gradient, so stopping at the six fills leaves those teal. The easiest way to get all of it right is the [playground](https://www.robertritacca.com/playground): restyle the system live and copy out a complete, paste-ready override.
99
100
 
100
101
  Icons use a bundled Material Symbols Rounded variable font (woff2): components import it themselves, so they need no extra setup. If you render raw `.material-symbols-rounded` spans of your own, import `@robr0/design-system/fonts/material-symbols.css` once. Nunito Sans, the system's default typeface, is intentionally *not* bundled: load it yourself (e.g. Google Fonts or `next/font`) or override `--font-family-primary`.
101
102
 
@@ -142,6 +143,8 @@ The AI layer also serves machines directly. The live site exposes a Model Contex
142
143
  claude mcp add --transport http robr0-ds https://robertritacca.com/api/mcp
143
144
  ```
144
145
 
146
+ The same generated data is served as files too: every component's prop contract lives at `https://robertritacca.com/components/<slug>.md` beside its docs page, and a generated agent skill (a `SKILL.md` plus its `references/components.md` catalogue, under `https://robertritacca.com/skill/robr0-design-system/`) can be saved as a pair into a project's own skills folder so a coding agent carries the library's install, theming and catalogue rules into every session. The [setup guide](https://robertritacca.com/docs/get-started) has the two-command install.
147
+
145
148
  ---
146
149
 
147
150
  ## Tech
@@ -150,20 +153,20 @@ claude mcp add --transport http robr0-ds https://robertritacca.com/api/mcp
150
153
  - **Vite 7**: dev server and library build
151
154
  - **Next.js 16**: portfolio site and design system documentation
152
155
  - **Storybook 10**: component explorer
153
- - **Vitest + Playwright + axe**: every Storybook story runs as a render test in headless Chromium, with an accessibility audit on each
156
+ - **Vitest + Playwright + axe**: every Storybook story runs as a render test in headless Chromium, with an accessibility audit on each and interaction assertions where a story carries a play function
154
157
  - **CSS custom properties**: all theming via semantic tokens, no CSS-in-JS
155
158
 
156
159
  ---
157
160
 
158
161
  ## Quality & CI
159
162
 
160
- Every push and pull request runs a GitHub Actions pipeline ([`ci.yml`](.github/workflows/ci.yml)) with four jobs: lint + library build, story tests, Storybook build, and website lint + build followed by the two built-HTML checks (rendered spacing, chat-corpus coverage). The story tests render every Storybook story in headless Chromium via Vitest and run an axe accessibility audit on each, so a violation fails the build exactly like a render error. The same checklist runs locally with one command:
163
+ Every push and pull request runs a GitHub Actions pipeline ([`ci.yml`](.github/workflows/ci.yml)) with four jobs: lint + library build + the package-publish lint, story tests, Storybook build, and website lint + build followed by the built-HTML checks (rendered spacing, chat-corpus coverage, internal links) and the served-site checks: a hydration smoke that loads the built site in a real browser, and a page-level axe pass over the served pages in both themes. The story tests render every Storybook story in headless Chromium via Vitest and run an axe accessibility audit on each, so a violation fails the build exactly like a render error; the overlay stories also assert their keyboard and focus behaviour with play functions, enforced the same way. A scheduled workflow ([`uptime.yml`](.github/workflows/uptime.yml)) re-runs the hydration smoke against production every four hours, because incremental regeneration means the served site can change with no deploy. The same checklist runs locally with one command:
161
164
 
162
165
  ```bash
163
- npm run verify # lint + library type-check + package build + story tests + Storybook build + website lint + build + built-HTML checks
166
+ npm run verify # lint + library type-check + package build + publish lint + story tests + Storybook build + website lint + build + the built-HTML and served-site checks
164
167
  ```
165
168
 
166
- CI also guards against documentation drift: generated surfaces (this README's component count and list, the website's skills pages, the published blueprint copies of the root markdown specs) are rebuilt from their source registries on every build, and CI fails if the committed copies are stale. The numbers on the site are never hand-written.
169
+ CI also guards against documentation drift: every generated surface (the `validate-registry` entry in the root `package.json` is the authoritative list, from this README's component count to the per-component markdown and the consumer agent skill) is rebuilt from its source registries on every build, and CI fails if the committed copies are stale. The numbers on the site are never hand-written.
167
170
 
168
171
  ---
169
172
 
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared focusability queries for the overlay behavior layer.
3
+ *
4
+ * One home for the selector the modal components previously each carried a
5
+ * copy of. Plain DOM utilities — no React, safe to import anywhere.
6
+ */
7
+ /** Everything a focus trap must cycle through. */
8
+ export declare const FOCUSABLE = "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
9
+ /**
10
+ * The subset worth landing initial focus on: real controls, not arbitrary
11
+ * tabindex carriers (a scrollable body region is tabbable but focusing it on
12
+ * open would strand a keyboard user on a non-control).
13
+ */
14
+ export declare const INITIAL_FOCUS = "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])";
15
+ /**
16
+ * The focusable elements inside a container, in DOM order, excluding
17
+ * invisible ones — a control hidden by `display: none` must not count as the
18
+ * trap's first or last stop.
19
+ */
20
+ export declare function getFocusable(container: HTMLElement): HTMLElement[];
21
+ /**
22
+ * Where initial focus should land when a scope activates: the first visible
23
+ * real control, then the first visible focusable of any kind, else null (the
24
+ * caller falls back to the container itself).
25
+ */
26
+ export declare function getInitialFocus(container: HTMLElement): HTMLElement | null;
@@ -0,0 +1,18 @@
1
+ const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
2
+ const INITIAL_FOCUS = "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])";
3
+ const isVisible = (el) => typeof el.checkVisibility === "function" ? el.checkVisibility() : el.offsetParent !== null;
4
+ function getFocusable(container) {
5
+ return Array.from(container.querySelectorAll(FOCUSABLE)).filter(isVisible);
6
+ }
7
+ function getInitialFocus(container) {
8
+ const preferred = Array.from(container.querySelectorAll(INITIAL_FOCUS)).find(
9
+ isVisible
10
+ );
11
+ return preferred ?? getFocusable(container)[0] ?? null;
12
+ }
13
+ export {
14
+ FOCUSABLE,
15
+ INITIAL_FOCUS,
16
+ getFocusable,
17
+ getInitialFocus
18
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Focus containment for modal overlays: initial focus, Tab wrapping, focus
3
+ * restore, and background isolation.
4
+ *
5
+ * Restore is guarded by `document.contains` — the element that opened a
6
+ * dialog can be gone by the time it closes (a row the dialog itself deleted),
7
+ * and restoring into a detached node silently drops focus to `<body>`. It
8
+ * also runs when the component unmounts while open, which the per-component
9
+ * implementations this replaces never handled.
10
+ *
11
+ * Background isolation uses `inert`, not `aria-hidden`: a JS focus trap only
12
+ * fences the Tab key, while a screen reader's virtual cursor walks the page
13
+ * behind the dialog freely. `inert` closes all three doors at once —
14
+ * assistive-tech traversal, focusability, and pointer events. Scopes stack:
15
+ * only the topmost active scope's overlay is interactive, and closing it
16
+ * hands the page back to the scope below, not to the whole document. The
17
+ * inert set is recomputed only when a scope activates or deactivates, so a
18
+ * portal that mounts *while* a modal is open (a toast, say) is not inerted
19
+ * until the stack next changes — under `aria-modal` that content is outside
20
+ * the dialog's world anyway.
21
+ */
22
+ type FocusScopeOptions = {
23
+ /** Whether the scope is active — the overlay's open state. */
24
+ active: boolean;
25
+ /** Wrap Tab / Shift+Tab within the scope. Default true. */
26
+ trap?: boolean;
27
+ /** Restore focus to the previously focused element on deactivate. Default true. */
28
+ restoreFocus?: boolean;
29
+ /**
30
+ * Where initial focus lands. Defaults to the first visible real control,
31
+ * then the first focusable of any kind, then the container itself.
32
+ */
33
+ initialFocus?: () => HTMLElement | null;
34
+ /** Make everything outside the scope inert while active. Default true. */
35
+ inertOutside?: boolean;
36
+ };
37
+ export declare function useFocusScope(ref: React.RefObject<HTMLElement | null>, options: FocusScopeOptions): void;
38
+ export {};
@@ -0,0 +1,82 @@
1
+ "use client";
2
+ import { useRef, useEffect } from "react";
3
+ import { getInitialFocus, getFocusable } from "./focusable.js";
4
+ const inertScopes = [];
5
+ const inerted = /* @__PURE__ */ new Set();
6
+ const applyInert = () => {
7
+ const top = inertScopes[inertScopes.length - 1];
8
+ for (const child of Array.from(document.body.children)) {
9
+ if (!(child instanceof HTMLElement)) continue;
10
+ if (child.tagName === "SCRIPT" || child.tagName === "STYLE") continue;
11
+ const shouldBeInert = top !== void 0 && !child.contains(top.panel);
12
+ if (shouldBeInert && !child.inert) {
13
+ child.inert = true;
14
+ inerted.add(child);
15
+ } else if (!shouldBeInert && inerted.has(child)) {
16
+ child.inert = false;
17
+ inerted.delete(child);
18
+ }
19
+ }
20
+ if (top === void 0 && inerted.size > 0) {
21
+ for (const el of inerted) el.inert = false;
22
+ inerted.clear();
23
+ }
24
+ };
25
+ function useFocusScope(ref, options) {
26
+ const optionsRef = useRef(options);
27
+ useEffect(() => {
28
+ optionsRef.current = options;
29
+ });
30
+ const { active } = options;
31
+ useEffect(() => {
32
+ if (!active) return;
33
+ const panel = ref.current;
34
+ if (!panel) return;
35
+ const previousFocus = document.activeElement;
36
+ let focusRaf = 0;
37
+ let focusAttempts = 0;
38
+ const tryInitialFocus = () => {
39
+ const initial = optionsRef.current.initialFocus?.() ?? getInitialFocus(panel) ?? panel;
40
+ initial.focus();
41
+ if (document.activeElement === initial) return;
42
+ focusAttempts += 1;
43
+ if (focusAttempts < 5) focusRaf = requestAnimationFrame(tryInitialFocus);
44
+ };
45
+ tryInitialFocus();
46
+ const scope = { panel };
47
+ if (optionsRef.current.inertOutside !== false) {
48
+ inertScopes.push(scope);
49
+ applyInert();
50
+ }
51
+ const handleKeyDown = (event) => {
52
+ if (event.key !== "Tab" || optionsRef.current.trap === false) return;
53
+ const focusable = getFocusable(panel);
54
+ if (focusable.length === 0) return;
55
+ const first = focusable[0];
56
+ const last = focusable[focusable.length - 1];
57
+ if (event.shiftKey && document.activeElement === first) {
58
+ event.preventDefault();
59
+ last.focus();
60
+ } else if (!event.shiftKey && document.activeElement === last) {
61
+ event.preventDefault();
62
+ first.focus();
63
+ }
64
+ };
65
+ document.addEventListener("keydown", handleKeyDown);
66
+ return () => {
67
+ cancelAnimationFrame(focusRaf);
68
+ document.removeEventListener("keydown", handleKeyDown);
69
+ const index = inertScopes.indexOf(scope);
70
+ if (index !== -1) {
71
+ inertScopes.splice(index, 1);
72
+ applyInert();
73
+ }
74
+ if (optionsRef.current.restoreFocus !== false && previousFocus && document.contains(previousFocus)) {
75
+ previousFocus.focus();
76
+ }
77
+ };
78
+ }, [active, ref]);
79
+ }
80
+ export {
81
+ useFocusScope
82
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The dismissal layer stack — the single owner of the document-level Escape
3
+ * and outside-press listeners for every overlay in the library.
4
+ *
5
+ * Before this existed, each open overlay registered its own document keydown,
6
+ * so Escape over a stacked Dialog-in-Drawer closed both at once. The stack
7
+ * routes each interaction to the **topmost** open layer only: Escape
8
+ * dismisses it (or is swallowed when it is not Escape-dismissible — a lower
9
+ * layer must never close through a blocking one), and an outside press
10
+ * dismisses it only when the press lands outside every element the layer
11
+ * claims. Layers below are untouched; one press, one layer.
12
+ *
13
+ * Exactly two document listeners exist while any layer is open, and none
14
+ * while the stack is empty. Registration order is stack order — overlays
15
+ * portal into `document.body` in the order they open, so stack order and
16
+ * paint order agree without reading z-index.
17
+ */
18
+ type LayerOptions = {
19
+ /** The overlay's open state — the layer registers while true. */
20
+ open: boolean;
21
+ /** Called when the stack decides this layer should dismiss. */
22
+ onDismiss?: () => void;
23
+ /** Whether Escape dismisses this layer. Default true. */
24
+ dismissOnEscape?: boolean;
25
+ /**
26
+ * Whether a pointerdown outside `getElements()` dismisses this layer.
27
+ * Default false — modal components keep their own backdrop click handler,
28
+ * which is part of their DOM and needs no document listener.
29
+ */
30
+ dismissOnOutsidePress?: boolean;
31
+ /**
32
+ * The elements that count as "inside" for outside-press checks — typically
33
+ * the panel and its trigger. Only read when `dismissOnOutsidePress` is on.
34
+ */
35
+ getElements?: () => (HTMLElement | null | undefined)[];
36
+ };
37
+ /**
38
+ * Register an overlay on the dismissal stack while `open` is true. Options
39
+ * are read at event time, so a `dismissOnEscape` toggle takes effect without
40
+ * re-registering (which would wrongly move the layer to the top).
41
+ */
42
+ export declare function useLayer(options: LayerOptions): void;
43
+ export {};
@@ -0,0 +1,55 @@
1
+ "use client";
2
+ import { useRef, useEffect } from "react";
3
+ const layers = [];
4
+ let listenersInstalled = false;
5
+ const handleKeyDown = (event) => {
6
+ if (event.key !== "Escape") return;
7
+ const top = layers[layers.length - 1];
8
+ if (!top) return;
9
+ const options = top.optionsRef.current;
10
+ if (options.dismissOnEscape === false) return;
11
+ event.preventDefault();
12
+ options.onDismiss?.();
13
+ };
14
+ const handlePointerDown = (event) => {
15
+ const top = layers[layers.length - 1];
16
+ if (!top) return;
17
+ const options = top.optionsRef.current;
18
+ if (!options.dismissOnOutsidePress) return;
19
+ const target = event.target instanceof Node ? event.target : null;
20
+ if (!target) return;
21
+ const inside = (options.getElements?.() ?? []).some((el) => el?.contains(target));
22
+ if (!inside) options.onDismiss?.();
23
+ };
24
+ const syncListeners = () => {
25
+ if (layers.length > 0 && !listenersInstalled) {
26
+ document.addEventListener("keydown", handleKeyDown);
27
+ document.addEventListener("pointerdown", handlePointerDown);
28
+ listenersInstalled = true;
29
+ } else if (layers.length === 0 && listenersInstalled) {
30
+ document.removeEventListener("keydown", handleKeyDown);
31
+ document.removeEventListener("pointerdown", handlePointerDown);
32
+ listenersInstalled = false;
33
+ }
34
+ };
35
+ function useLayer(options) {
36
+ const optionsRef = useRef(options);
37
+ useEffect(() => {
38
+ optionsRef.current = options;
39
+ });
40
+ const { open } = options;
41
+ useEffect(() => {
42
+ if (!open) return;
43
+ const layer = { optionsRef };
44
+ layers.push(layer);
45
+ syncListeners();
46
+ return () => {
47
+ const index = layers.indexOf(layer);
48
+ if (index !== -1) layers.splice(index, 1);
49
+ syncListeners();
50
+ };
51
+ }, [open]);
52
+ }
53
+ export {
54
+ useLayer
55
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * False during SSR and the hydration render, true afterwards — the guard a
3
+ * portal component needs before touching `document.body`. Uses
4
+ * `useSyncExternalStore` rather than a mount effect so the client's first
5
+ * render matches the server HTML without an extra render pass.
6
+ */
7
+ export declare function useMounted(): boolean;
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { useSyncExternalStore } from "react";
3
+ const emptySubscribe = () => () => {
4
+ };
5
+ function useMounted() {
6
+ return useSyncExternalStore(
7
+ emptySubscribe,
8
+ () => true,
9
+ () => false
10
+ );
11
+ }
12
+ export {
13
+ useMounted
14
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Page scroll locking for modal overlays, with two strategies and one owner.
3
+ *
4
+ * `pin` (the default) fixes the body in place rather than hiding its
5
+ * overflow. iOS Safari ignores `overflow: hidden` on the body: a touch still
6
+ * scrolls the document, and focusing a field scrolls it programmatically to
7
+ * bring the field above the keyboard — which slides the page out from under
8
+ * the overlay. A fixed body has no scrollable overflow at all, so neither can
9
+ * move it. The body is pinned at its current scroll offset so the page behind
10
+ * the overlay does not visibly jump, and the offset is restored when the last
11
+ * holder releases. `overflow: hidden` rides along so the pinned body cannot
12
+ * grow scrollbars of its own.
13
+ *
14
+ * The pin is **counted**, and the counter is the whole point: closing a
15
+ * Dialog opened over a Drawer must not unlock the Drawer's scroll. The
16
+ * imperative pair (`acquireScrollLock` / `releaseScrollLock`) exposes the
17
+ * same counter to host chrome — a site's nav drawer, a bespoke chat panel —
18
+ * because two locks that cannot see each other each believe they own
19
+ * `document.body`, and whichever releases first unlocks the page under the
20
+ * overlay the other still holds (or restores styles the other already
21
+ * changed). One counter means the first acquire captures and pins, only the
22
+ * last release restores, whichever module it belongs to. The captured inline
23
+ * styles are restored verbatim, never blanked, so a host's own body styling
24
+ * survives a lock cycle.
25
+ *
26
+ * `cancel-events` keeps the document scrollbar rendered and cancels scroll
27
+ * *input* instead — wheel and touch gestures work only inside `allowWithin`.
28
+ * This is CommandPalette's documented design decision (unscrollable-body
29
+ * treatments blink the scrollbar rail out, which reads as a layout jump on
30
+ * classic-scrollbar platforms); it is deliberately not the default because
31
+ * swapping the modals onto it would change their scrollbar rendering.
32
+ * Instances are independent — overlapping cancel-events locks stack
33
+ * harmlessly.
34
+ */
35
+ type ScrollLockOptions = {
36
+ /** Which locking strategy to use. Default `'pin'`. */
37
+ strategy?: 'pin' | 'cancel-events';
38
+ /** For `cancel-events`: the scrollable region that keeps receiving input. */
39
+ allowWithin?: React.RefObject<HTMLElement | null>;
40
+ };
41
+ /**
42
+ * Take one hold on the shared page scroll lock — the counter behind the
43
+ * hook's `pin` strategy. For overlays outside this library (host chrome)
44
+ * that must not fight the modal components over `document.body`. Every
45
+ * acquire needs exactly one matching release.
46
+ */
47
+ export declare function acquireScrollLock(): void;
48
+ /**
49
+ * Release one hold on the shared page scroll lock. The body styles and the
50
+ * scroll offset are restored only when the last hold anywhere releases; an
51
+ * unbalanced release never touches the page.
52
+ */
53
+ export declare function releaseScrollLock(): void;
54
+ export declare function useScrollLock(active: boolean, options?: ScrollLockOptions): void;
55
+ export {};
@@ -0,0 +1,59 @@
1
+ "use client";
2
+ import { useRef, useEffect } from "react";
3
+ const PINNED_PROPS = ["position", "top", "left", "right", "overflow"];
4
+ let pinCount = 0;
5
+ let pinnedScrollY = 0;
6
+ let previousBodyStyle = {};
7
+ function acquireScrollLock() {
8
+ if (pinCount === 0) {
9
+ const { style } = document.body;
10
+ pinnedScrollY = window.scrollY;
11
+ previousBodyStyle = {};
12
+ for (const prop of PINNED_PROPS) previousBodyStyle[prop] = style[prop];
13
+ style.position = "fixed";
14
+ style.top = `-${pinnedScrollY}px`;
15
+ style.left = "0";
16
+ style.right = "0";
17
+ style.overflow = "hidden";
18
+ }
19
+ pinCount += 1;
20
+ }
21
+ function releaseScrollLock() {
22
+ if (pinCount === 0) return;
23
+ pinCount -= 1;
24
+ if (pinCount > 0) return;
25
+ const { style } = document.body;
26
+ for (const prop of PINNED_PROPS) style[prop] = previousBodyStyle[prop] ?? "";
27
+ window.scrollTo({ top: pinnedScrollY, behavior: "instant" });
28
+ }
29
+ function useScrollLock(active, options) {
30
+ const optionsRef = useRef(options);
31
+ useEffect(() => {
32
+ optionsRef.current = options;
33
+ });
34
+ const strategy = options?.strategy ?? "pin";
35
+ useEffect(() => {
36
+ if (!active) return;
37
+ if (strategy === "pin") {
38
+ acquireScrollLock();
39
+ return releaseScrollLock;
40
+ }
41
+ const block = (event) => {
42
+ const allow = optionsRef.current?.allowWithin?.current;
43
+ const target = event.target instanceof Node ? event.target : null;
44
+ if (allow && target && allow.contains(target)) return;
45
+ event.preventDefault();
46
+ };
47
+ document.addEventListener("wheel", block, { passive: false });
48
+ document.addEventListener("touchmove", block, { passive: false });
49
+ return () => {
50
+ document.removeEventListener("wheel", block);
51
+ document.removeEventListener("touchmove", block);
52
+ };
53
+ }, [active, strategy]);
54
+ }
55
+ export {
56
+ acquireScrollLock,
57
+ releaseScrollLock,
58
+ useScrollLock
59
+ };
package/charts.d.ts CHANGED
@@ -13,3 +13,4 @@ export * from './components/Chart/RadialChart';
13
13
  export * from './components/Chart/ScatterChart';
14
14
  export * from './components/Chart/StackedBarChart';
15
15
  export * from './components/Chart/Treemap';
16
+ export * from './components/FunnelChart/FunnelChart';
package/charts.js CHANGED
@@ -8,10 +8,12 @@ import { RadialChart } from "./components/Chart/RadialChart.js";
8
8
  import { ScatterChart } from "./components/Chart/ScatterChart.js";
9
9
  import { StackedBarChart } from "./components/Chart/StackedBarChart.js";
10
10
  import { Treemap } from "./components/Chart/Treemap.js";
11
+ import { FunnelChart } from "./components/FunnelChart/FunnelChart.js";
11
12
  export {
12
13
  AreaChart,
13
14
  BarChart,
14
15
  ComboChart,
16
+ FunnelChart,
15
17
  LineChart,
16
18
  PieChart,
17
19
  RadarChart,
@@ -151,6 +151,11 @@
151
151
  align-items: center;
152
152
  justify-content: center;
153
153
  flex: none;
154
+ /* Fixed box: the active step's spinner (16px) is smaller than the step
155
+ icons (20px), so without this the column narrows and the spinner drifts
156
+ off the connector's centre line. */
157
+ width: var(--icon-size-sm);
158
+ height: var(--icon-size-sm);
154
159
  color: var(--color-icon-secondary);
155
160
  }
156
161
 
@@ -188,8 +193,12 @@
188
193
  STEP STATES
189
194
  ============================================ */
190
195
 
196
+ .ds-agent-plan__step--pending .ds-agent-plan__indicator {
197
+ color: var(--color-status-neutral-icon);
198
+ }
199
+
191
200
  .ds-agent-plan__step--completed .ds-agent-plan__indicator {
192
- color: var(--color-status-positive-text);
201
+ color: var(--color-status-positive-icon);
193
202
  }
194
203
 
195
204
  .ds-agent-plan__step--completed .ds-agent-plan__label {
@@ -197,7 +206,7 @@
197
206
  }
198
207
 
199
208
  .ds-agent-plan__step--active .ds-agent-plan__indicator {
200
- color: var(--color-status-info-text);
209
+ color: var(--color-status-info-icon);
201
210
  }
202
211
 
203
212
  .ds-agent-plan__step--active .ds-agent-plan__label {
@@ -207,7 +216,7 @@
207
216
  }
208
217
 
209
218
  .ds-agent-plan__step--failed .ds-agent-plan__indicator {
210
- color: var(--color-status-error-text);
219
+ color: var(--color-status-error-icon);
211
220
  }
212
221
 
213
222
  .ds-agent-plan__step--failed .ds-agent-plan__label {