@robr0/design-system 0.10.0 → 0.11.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 (93) hide show
  1. package/README.md +7 -6
  2. package/components/AgentPlan/AgentPlan.css +14 -3
  3. package/components/AiButton/AiButton.css +1 -1
  4. package/components/Alert/Alert.css +9 -0
  5. package/components/AlertDialog/AlertDialog.css +3 -1
  6. package/components/AlertDialog/AlertDialog.js +2 -3
  7. package/components/AppSidebar/AppSidebar.css +9 -0
  8. package/components/Card/Card.css +7 -2
  9. package/components/CardStack/CardStack.css +106 -0
  10. package/components/CardStack/CardStack.d.ts +44 -0
  11. package/components/CardStack/CardStack.js +140 -0
  12. package/components/Carousel/Carousel.css +9 -0
  13. package/components/Carousel/Carousel.js +37 -4
  14. package/components/Chart/AreaChart.js +6 -5
  15. package/components/Chart/LineChart.js +4 -3
  16. package/components/Chart/PieChart.js +2 -15
  17. package/components/Chart/RadarChart.js +5 -4
  18. package/components/Chart/RadialChart.js +2 -15
  19. package/components/Chart/ScatterChart.js +3 -13
  20. package/components/Chart/StackedBarChart.js +2 -15
  21. package/components/Chart/Treemap.js +3 -17
  22. package/components/Chart/palette.d.ts +1 -0
  23. package/components/Chart/palette.js +19 -0
  24. package/components/ChatMarker/ChatMarker.css +9 -0
  25. package/components/ChatMessage/ChatMessage.css +16 -0
  26. package/components/ChatThread/ChatThread.js +2 -2
  27. package/components/Chip/Chip.css +9 -0
  28. package/components/CodeBlock/CodeBlock.css +10 -1
  29. package/components/CodeBlock/CodeBlock.js +2 -1
  30. package/components/ColorPicker/ColorPicker.js +10 -0
  31. package/components/Combobox/Combobox.css +9 -0
  32. package/components/CommandPalette/CommandPalette.css +9 -0
  33. package/components/CommandPalette/CommandPalette.js +31 -5
  34. package/components/Composer/Composer.css +118 -0
  35. package/components/Composer/Composer.d.ts +7 -0
  36. package/components/Composer/Composer.js +7 -1
  37. package/components/ContactCard/ContactCard.css +9 -0
  38. package/components/ContextMenu/ContextMenu.js +39 -3
  39. package/components/DatePicker/DatePicker.js +16 -0
  40. package/components/Dialog/Dialog.css +3 -1
  41. package/components/Dialog/Dialog.js +5 -4
  42. package/components/DocumentChip/DocumentChip.css +9 -0
  43. package/components/Dropdown/Dropdown.js +22 -3
  44. package/components/DropdownMenu/DropdownMenu.js +43 -6
  45. package/components/EmptyState/EmptyState.css +9 -0
  46. package/components/FileInput/FileInput.css +9 -0
  47. package/components/Globe/Globe.css +155 -0
  48. package/components/Globe/Globe.d.ts +96 -0
  49. package/components/Globe/Globe.js +415 -0
  50. package/components/InterruptCard/InterruptCard.css +27 -17
  51. package/components/InterruptCard/InterruptCard.d.ts +1 -1
  52. package/components/InterruptCard/InterruptCard.js +1 -1
  53. package/components/MapCallout/MapCallout.css +46 -0
  54. package/components/MapCallout/MapCallout.d.ts +29 -0
  55. package/components/MapCallout/MapCallout.js +17 -0
  56. package/components/MapLegend/MapLegend.css +88 -0
  57. package/components/MapLegend/MapLegend.d.ts +39 -0
  58. package/components/MapLegend/MapLegend.js +68 -0
  59. package/components/MessageActions/MessageActions.css +9 -0
  60. package/components/MessageCard/MessageCard.css +68 -16
  61. package/components/MessageCard/MessageCard.d.ts +1 -1
  62. package/components/MessageCard/MessageCard.js +6 -4
  63. package/components/ModelPicker/ModelPicker.css +4 -1
  64. package/components/ModelPicker/ModelPicker.js +43 -1
  65. package/components/NotificationCenter/NotificationCenter.css +9 -0
  66. package/components/NotificationCenter/NotificationCenter.js +26 -10
  67. package/components/NumberInput/NumberInput.css +9 -0
  68. package/components/Popover/Popover.css +3 -1
  69. package/components/Popover/Popover.js +2 -1
  70. package/components/SourceChip/SourceChip.css +9 -0
  71. package/components/Stat/Stat.css +9 -0
  72. package/components/Stepper/Stepper.css +9 -0
  73. package/components/TagInput/TagInput.css +9 -0
  74. package/components/TimePicker/TimePicker.js +4 -1
  75. package/components/Toast/Toast.css +32 -11
  76. package/components/Toast/Toast.js +5 -4
  77. package/components/ToggleGroup/ToggleGroup.css +9 -0
  78. package/components/ToolCall/ToolCall.css +18 -6
  79. package/components/Tooltip/Tooltip.js +20 -16
  80. package/components/registry.json +37 -0
  81. package/components/registry.json.d.ts +37 -0
  82. package/components/registry.json.js +2 -2
  83. package/index.d.ts +4 -0
  84. package/index.js +8 -0
  85. package/package.json +5 -1
  86. package/tokens/motion.d.ts +23 -0
  87. package/tokens/motion.js +14 -0
  88. package/tokens/registry.json +17 -0
  89. package/tokens/registry.json.d.ts +17 -0
  90. package/tokens/registry.json.js +1 -1
  91. package/tokens/tokens-dark.css +11 -0
  92. package/tokens/tokens-light.css +11 -0
  93. package/tokens/tokens-typography.css +20 -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 -->101<!-- /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 -->105<!-- /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. |
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 · Badge · Bar chart · Breadcrumb · Button · Button group · Card · Carousel · Chat header · Chat marker · Chat message · Chat thread · Checkbox · Chip · Circular button · Code block · Code diff · Colour picker · 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 · Input · Instructions · Interrupt card · Kbd · Line chart · Link list · Message actions · Message card · Model picker · Nav · Nav list · Notification centre · Number input · Pagination · Pie chart · Pin input · Popover · Progress bar · Prompt suggestions · Prose · Quote · Radar chart · Radial chart · Radio button · Reasoning · Scatter chart · Section title · Segmented control · Selection card · Shader field · Skeleton · Slider · Source chip · Sparkline · Spinner · Stacked bar chart · Stat · Stepper · 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 · Badge · 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 · 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 · Globe · Input · Instructions · Interrupt card · Kbd · Line chart · Link list · Map callout · Map legend · Message actions · Message card · Model picker · Nav · Nav list · Notification centre · Number input · Pagination · Pie chart · Pin input · Popover · Progress bar · Prompt suggestions · Prose · Quote · Radar chart · Radial chart · Radio button · Reasoning · Scatter chart · Section title · Segmented control · Selection card · Shader field · Skeleton · Slider · Source chip · Sparkline · Spinner · Stacked bar chart · Stat · Stepper · Swatch · Table · Tabs · Tag input · Textarea · Time picker · Timeline · Toast · Toggle group · Toggle switch · Tool call · Tooltip · Tree view · Treemap
32
32
  <!-- component-list:end -->
33
33
 
34
34
  ### Ambient background
@@ -50,7 +50,7 @@ Eight parameters describe the look, and a composition is a table of token names
50
50
 
51
51
  It asks two things of you, both deliberate. It fills a positioned ancestor you provide, because where a background sits is a layout decision rather than a rendering one. And it never decides what to paint instead of itself: it reports `pending`, `active` or `unavailable`, and one fallback covers every way it can fail (no WebGL2, a blocked or lost GPU context, a renderer that stalls before its first frame, or `enabled={false}` as a kill switch). `pending` is the state worth handling. Paint neither layer while the context comes up, or the swap a frame later reads as two backgrounds loading in sequence.
52
52
 
53
- It is also the one place a `prefers-reduced-motion` check lives in JavaScript rather than in the motion tokens, because the CSS guard cannot see a `requestAnimationFrame` loop. Set the preference and it draws a single static frame.
53
+ It checks `prefers-reduced-motion` in JavaScript rather than through the motion tokens, because the CSS guard cannot see a JS-driven loop. Set the preference and it draws a single static frame.
54
54
 
55
55
  The background behind this site is that component, with eight blurred CSS discs kept painted underneath as its fallback. The **[component page](https://robertritacca.com/components/shader-field)** is a live demo, and the **[setup guide](https://robertritacca.com/docs/get-started)** has the wiring.
56
56
 
@@ -97,7 +97,7 @@ import { BarChart, LineChart } from '@robr0/design-system/charts';
97
97
  }
98
98
  ```
99
99
 
100
- Icons use a bundled Material Symbols Rounded variable font (woff2), with no extra setup. 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`.
100
+ 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
101
 
102
102
  ### Token architecture
103
103
 
@@ -164,11 +164,12 @@ CI also guards against documentation drift: generated surfaces (this README's co
164
164
  ## Running locally
165
165
 
166
166
  ```bash
167
+ npm install # once, at the root: the website is an npm workspace, so this installs both
168
+
167
169
  # Storybook (the library's dev sandbox)
168
170
  npm run storybook # http://localhost:6006
169
171
 
170
- # Portfolio + documentation website (npm workspace: install once at the root)
171
- npm install
172
+ # Portfolio + documentation website
172
173
  npm run dev --workspace website # http://localhost:3000
173
174
  ```
174
175
 
@@ -16,7 +16,8 @@
16
16
  width: 100%;
17
17
  background-color: var(--color-bg-container-primary);
18
18
  border: var(--border-xs) solid var(--color-bg-container-border);
19
- border-radius: var(--radius-md);
19
+ /* The chat furniture's shared 24px shell — see ToolCall. */
20
+ border-radius: var(--radius-xl);
20
21
  overflow: hidden;
21
22
  }
22
23
 
@@ -29,7 +30,8 @@
29
30
  align-items: center;
30
31
  gap: var(--gap-sm);
31
32
  width: 100%;
32
- padding: var(--padding-sm) var(--padding-md);
33
+ /* 20px sides — the card family's shared content inset. */
34
+ padding: var(--padding-sm) var(--padding-lg);
33
35
  background: none;
34
36
  border: none;
35
37
  text-align: left;
@@ -121,7 +123,7 @@
121
123
  display: flex;
122
124
  flex-direction: column;
123
125
  margin: 0;
124
- padding: var(--padding-sm-md) var(--padding-md);
126
+ padding: var(--padding-sm-md) var(--padding-lg);
125
127
  list-style: none;
126
128
  }
127
129
 
@@ -229,3 +231,12 @@
229
231
  white-space: nowrap;
230
232
  border: 0;
231
233
  }
234
+
235
+ /* Icons take the colour of the text around them. Explicit, not just
236
+ inherited by default: a consumer stylesheet that sets a colour on
237
+ .material-symbols-rounded itself (a common global default) would
238
+ otherwise override inheritance and detach the icon from the state
239
+ colours this component sets on its parents. */
240
+ .ds-agent-plan .material-symbols-rounded {
241
+ color: inherit;
242
+ }
@@ -60,7 +60,7 @@
60
60
  position: absolute;
61
61
  inset: 0;
62
62
  border-radius: inherit;
63
- padding: var(--border-xs);
63
+ padding: var(--border-md);
64
64
  background: conic-gradient(
65
65
  from var(--ds-ai-button-angle),
66
66
  var(--color-ai-gradient-start),
@@ -250,3 +250,12 @@
250
250
  .ds-alert--neutral .ds-alert__dismiss {
251
251
  color: var(--color-status-neutral-text);
252
252
  }
253
+
254
+ /* Icons take the colour of the text around them. Explicit, not just
255
+ inherited by default: a consumer stylesheet that sets a colour on
256
+ .material-symbols-rounded itself (a common global default) would
257
+ otherwise override inheritance and detach the icon from the state
258
+ colours this component sets on its parents. */
259
+ .ds-alert .material-symbols-rounded {
260
+ color: inherit;
261
+ }
@@ -11,12 +11,14 @@
11
11
  align-items: center;
12
12
  justify-content: center;
13
13
  opacity: 0;
14
+ visibility: hidden; /* keeps panel controls out of the tab order when closed */
14
15
  pointer-events: none;
15
- transition: opacity var(--motion-duration-base) var(--motion-ease-standard);
16
+ transition: opacity var(--motion-duration-base) var(--motion-ease-standard), visibility var(--motion-duration-base) var(--motion-ease-standard);
16
17
  }
17
18
 
18
19
  .ds-alert-dialog--open {
19
20
  opacity: 1;
21
+ visibility: visible;
20
22
  pointer-events: auto;
21
23
  }
22
24
 
@@ -7,6 +7,7 @@ import "./AlertDialog.css";
7
7
  import "../../fonts/material-symbols.css";
8
8
  const emptySubscribe = () => () => {
9
9
  };
10
+ const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
10
11
  const AlertDialog = ({
11
12
  open,
12
13
  onOpenChange,
@@ -53,9 +54,7 @@ const AlertDialog = ({
53
54
  return;
54
55
  }
55
56
  if (e.key === "Tab" && panel) {
56
- const focusable = panel.querySelectorAll(
57
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
58
- );
57
+ const focusable = panel.querySelectorAll(FOCUSABLE);
59
58
  if (focusable.length === 0) return;
60
59
  const first = focusable[0];
61
60
  const last = focusable[focusable.length - 1];
@@ -442,3 +442,12 @@
442
442
  padding: var(--padding-lg) 0;
443
443
  }
444
444
  }
445
+
446
+ /* Icons take the colour of the text around them. Explicit, not just
447
+ inherited by default: a consumer stylesheet that sets a colour on
448
+ .material-symbols-rounded itself (a common global default) would
449
+ otherwise override inheritance and detach the icon from the state
450
+ colours this component sets on its parents. */
451
+ .ds-app-sidebar .material-symbols-rounded {
452
+ color: inherit;
453
+ }
@@ -76,7 +76,9 @@
76
76
  .ds-card--case-study {
77
77
  height: auto;
78
78
  min-width: 0;
79
- padding: 0;
79
+ /* The cover is inset, not full-bleed: 12px of card around a 12px-radius
80
+ cover keeps the corners concentric with the card's 24px shell. */
81
+ padding: var(--padding-sm-md);
80
82
  gap: 0;
81
83
  overflow: hidden;
82
84
  cursor: pointer;
@@ -103,6 +105,7 @@
103
105
  width: 100%;
104
106
  aspect-ratio: 940 / 480;
105
107
  overflow: hidden;
108
+ border-radius: var(--radius-md);
106
109
  background: var(--color-bg-container-secondary);
107
110
  }
108
111
 
@@ -142,7 +145,9 @@
142
145
  display: flex;
143
146
  flex-direction: column;
144
147
  gap: var(--gap-sm);
145
- padding: var(--padding-lg);
148
+ /* 8px on top of the card's 12px inset: text lands 20px from the card edge
149
+ (the old full-bleed body padding) and sits slightly inside the cover. */
150
+ padding: var(--padding-md) var(--padding-sm) var(--padding-sm-md);
146
151
  }
147
152
 
148
153
  .ds-card--case-study__eyebrow {
@@ -0,0 +1,106 @@
1
+ /* ============================================
2
+ CARD STACK COMPONENT
3
+ A deck showing one card, the rest peeking out
4
+ beneath. Forward flips lift the top card away;
5
+ backward flips drop it in from above. Depth
6
+ offsets are percentages of the card itself, so
7
+ the choreography scales with whatever card the
8
+ deck holds.
9
+ ============================================ */
10
+
11
+ /* Base */
12
+
13
+ .ds-card-stack {
14
+ /* How far each buried card peeks out below the one above it. */
15
+ --ds-card-stack-peek: var(--gap-md);
16
+
17
+ position: relative;
18
+ display: grid;
19
+ border-radius: var(--radius-xl);
20
+ padding-bottom: calc(var(--ds-card-stack-peek) * var(--ds-card-stack-depth, 0));
21
+ outline: none;
22
+ }
23
+
24
+ .ds-card-stack:focus-visible {
25
+ outline: var(--border-md) solid var(--color-action-primary-bg);
26
+ outline-offset: var(--padding-xxs);
27
+ }
28
+
29
+ /* Items — every card occupies the same cell; position is one transform. */
30
+
31
+ .ds-card-stack__item {
32
+ grid-area: 1 / 1;
33
+ /* An opaque page-colour backing behind each card: the system's cards sit on
34
+ translucent surfaces, and without this the buried cards read through the
35
+ top one. Rounded to Card's radius; a deck of differently shaped cards
36
+ repoints --ds-card-stack-card-radius. */
37
+ background: var(--color-bg-page-primary);
38
+ border-radius: var(--ds-card-stack-card-radius, var(--radius-xl));
39
+ transform-origin: 50% 100%;
40
+ transform: translateY(calc(var(--ds-card-stack-peek) * var(--ds-card-stack-pos, 0)))
41
+ scale(calc(1 - 0.04 * var(--ds-card-stack-pos, 0)));
42
+ transition:
43
+ transform var(--motion-duration-deliberate) var(--motion-ease-emphasized),
44
+ opacity var(--motion-duration-deliberate) var(--motion-ease-emphasized);
45
+ }
46
+
47
+ /* Deeper than the visible peek: parked at the last slot, faded out. */
48
+ .ds-card-stack__item--buried {
49
+ opacity: 0;
50
+ transform: translateY(calc(var(--ds-card-stack-peek) * var(--ds-card-stack-depth, 0)))
51
+ scale(calc(1 - 0.04 * var(--ds-card-stack-depth, 0)));
52
+ }
53
+
54
+ /* Flipped away with loop off: lifted out of view, above the deck. */
55
+ .ds-card-stack__item--gone {
56
+ opacity: 0;
57
+ transform: translateY(-12%) scale(0.98);
58
+ }
59
+
60
+ /* Flip choreography — the lift is a fraction of the card's own height. */
61
+
62
+ .ds-card-stack__item--leaving {
63
+ animation: ds-card-stack-leave var(--motion-duration-deliberate) var(--motion-ease-emphasized)
64
+ both;
65
+ }
66
+
67
+ .ds-card-stack__item--entering {
68
+ animation: ds-card-stack-enter var(--motion-duration-deliberate) var(--motion-ease-entrance)
69
+ both;
70
+ }
71
+
72
+ @keyframes ds-card-stack-leave {
73
+ from {
74
+ transform: translateY(0) rotate(0deg) scale(1);
75
+ opacity: 1;
76
+ }
77
+ to {
78
+ transform: translateY(-18%) rotate(-3deg) scale(0.98);
79
+ opacity: 0;
80
+ }
81
+ }
82
+
83
+ @keyframes ds-card-stack-enter {
84
+ from {
85
+ transform: translateY(-18%) rotate(-3deg) scale(0.98);
86
+ opacity: 0;
87
+ }
88
+ to {
89
+ transform: translateY(0) rotate(0deg) scale(1);
90
+ opacity: 1;
91
+ }
92
+ }
93
+
94
+ /* Screen-reader announcement of the deck position. */
95
+
96
+ .ds-card-stack__sr-only {
97
+ position: absolute;
98
+ width: 1px;
99
+ height: 1px;
100
+ padding: 0;
101
+ margin: -1px;
102
+ overflow: hidden;
103
+ clip: rect(0, 0, 0, 0);
104
+ white-space: nowrap;
105
+ border: 0;
106
+ }
@@ -0,0 +1,44 @@
1
+ import { default as React } from 'react';
2
+ /** Props owned by CardStack itself — everything else falls through to the root `<div>`. */
3
+ type CardStackOwnProps = {
4
+ /** The cards, in order. The first child starts on top. */
5
+ children?: React.ReactNode;
6
+ /**
7
+ * Controlled top card, as an index into `children`. Pair with
8
+ * `onIndexChange`. Omit to let the stack own its position.
9
+ */
10
+ index?: number;
11
+ /** Initial top card when uncontrolled. */
12
+ defaultIndex?: number;
13
+ /** Fires when the top card changes, with the new index. */
14
+ onIndexChange?: (index: number) => void;
15
+ /** How many card edges peek out behind the top card. */
16
+ peek?: number;
17
+ /** Wrap from the last card back to the first. Off, the stack stops at both ends. */
18
+ loop?: boolean;
19
+ /**
20
+ * Clicking the top card flips to the next. Clicks that land on a link or
21
+ * control inside the card are left alone, so a card can still carry its
22
+ * own actions.
23
+ */
24
+ advanceOnClick?: boolean;
25
+ /** Accessible name for the stack, e.g. "Open roles". */
26
+ label?: string;
27
+ /** Additional CSS classes */
28
+ className?: string;
29
+ };
30
+ export interface CardStackProps extends CardStackOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof CardStackOwnProps> {
31
+ }
32
+ /**
33
+ * CardStack — a deck of cards showing one at a time, the rest peeking out
34
+ * beneath it. Flipping forward lifts the top card up and away while the next
35
+ * one rises into place; flipping back drops the previous card in from above.
36
+ * The stack is the pattern, not the cards: any children work, Card and
37
+ * EntityCard are the intended fillings, and every card should share one size
38
+ * so the deck keeps its shape.
39
+ *
40
+ * Flip by clicking the top card, or with the arrow keys when the stack has
41
+ * focus. Cards behind the top one are inert until their turn.
42
+ */
43
+ export declare const CardStack: React.ForwardRefExoticComponent<CardStackProps & React.RefAttributes<HTMLDivElement>>;
44
+ export {};
@@ -0,0 +1,140 @@
1
+ "use client";
2
+ import { jsxs, jsx } from "react/jsx-runtime";
3
+ import React, { useState, useRef, useCallback } from "react";
4
+ import "./CardStack.css";
5
+ const INTERACTIVE_SELECTOR = 'a, button, input, select, textarea, summary, [role="button"], [role="link"], [tabindex]';
6
+ const NEXT_KEYS = /* @__PURE__ */ new Set(["ArrowRight", "ArrowDown", " ", "Enter"]);
7
+ const PREV_KEYS = /* @__PURE__ */ new Set(["ArrowLeft", "ArrowUp"]);
8
+ const CardStack = React.forwardRef(
9
+ ({
10
+ children,
11
+ index: indexProp,
12
+ defaultIndex = 0,
13
+ onIndexChange,
14
+ peek = 2,
15
+ loop = true,
16
+ advanceOnClick = true,
17
+ label,
18
+ className = "",
19
+ onClick,
20
+ onKeyDown,
21
+ style,
22
+ ...rest
23
+ }, ref) => {
24
+ const baseClass = "ds-card-stack";
25
+ const items = React.Children.toArray(children);
26
+ const count = items.length;
27
+ const isControlled = indexProp !== void 0;
28
+ const [innerIndex, setInnerIndex] = useState(defaultIndex);
29
+ const index = Math.max(0, Math.min(isControlled ? indexProp : innerIndex, count - 1));
30
+ const [leaving, setLeaving] = useState(false);
31
+ const [entering, setEntering] = useState(false);
32
+ const pendingRef = useRef(null);
33
+ const commit = useCallback(
34
+ (next2) => {
35
+ const normalized = (next2 % count + count) % count;
36
+ if (!isControlled) setInnerIndex(normalized);
37
+ onIndexChange?.(normalized);
38
+ },
39
+ [count, isControlled, onIndexChange]
40
+ );
41
+ const next = useCallback(() => {
42
+ if (count < 2 || leaving || entering) return;
43
+ if (!loop && index >= count - 1) return;
44
+ pendingRef.current = index + 1;
45
+ setLeaving(true);
46
+ }, [count, leaving, entering, loop, index]);
47
+ const prev = useCallback(() => {
48
+ if (count < 2 || leaving || entering) return;
49
+ if (!loop && index <= 0) return;
50
+ commit(index - 1);
51
+ setEntering(true);
52
+ }, [count, leaving, entering, loop, index, commit]);
53
+ const handleTopAnimationEnd = (e) => {
54
+ if (e.target !== e.currentTarget) return;
55
+ if (leaving) {
56
+ setLeaving(false);
57
+ if (pendingRef.current !== null) commit(pendingRef.current);
58
+ pendingRef.current = null;
59
+ }
60
+ if (entering) setEntering(false);
61
+ };
62
+ const handleClick = (e) => {
63
+ onClick?.(e);
64
+ if (!advanceOnClick || e.defaultPrevented) return;
65
+ const target = e.target;
66
+ const control = target.closest(INTERACTIVE_SELECTOR);
67
+ if (control && control !== e.currentTarget) return;
68
+ next();
69
+ };
70
+ const handleKeyDown = (e) => {
71
+ onKeyDown?.(e);
72
+ if (e.defaultPrevented || e.target !== e.currentTarget) return;
73
+ if (NEXT_KEYS.has(e.key)) {
74
+ e.preventDefault();
75
+ next();
76
+ } else if (PREV_KEYS.has(e.key)) {
77
+ e.preventDefault();
78
+ prev();
79
+ }
80
+ };
81
+ const visiblePeek = Math.max(0, Math.min(peek, count - 1));
82
+ const classes = [baseClass, className].filter(Boolean).join(" ");
83
+ return /* @__PURE__ */ jsxs(
84
+ "div",
85
+ {
86
+ ...rest,
87
+ ref,
88
+ className: classes,
89
+ role: "group",
90
+ "aria-roledescription": "card stack",
91
+ "aria-label": label,
92
+ tabIndex: count > 1 ? 0 : void 0,
93
+ style: { ...style, "--ds-card-stack-depth": visiblePeek },
94
+ onClick: handleClick,
95
+ onKeyDown: handleKeyDown,
96
+ children: [
97
+ items.map((child, k) => {
98
+ const raw = k - index;
99
+ const pos = loop ? (raw % count + count) % count : raw;
100
+ const gone = pos < 0;
101
+ const buried = pos > visiblePeek;
102
+ const isTop = pos === 0;
103
+ const itemClasses = [
104
+ `${baseClass}__item`,
105
+ gone ? `${baseClass}__item--gone` : "",
106
+ buried ? `${baseClass}__item--buried` : "",
107
+ isTop && leaving ? `${baseClass}__item--leaving` : "",
108
+ isTop && entering ? `${baseClass}__item--entering` : ""
109
+ ].filter(Boolean).join(" ");
110
+ return /* @__PURE__ */ jsx(
111
+ "div",
112
+ {
113
+ className: itemClasses,
114
+ style: {
115
+ "--ds-card-stack-pos": gone ? 0 : Math.min(pos, visiblePeek),
116
+ zIndex: count - pos
117
+ },
118
+ inert: !isTop || void 0,
119
+ "aria-hidden": !isTop || void 0,
120
+ onAnimationEnd: isTop ? handleTopAnimationEnd : void 0,
121
+ children: child
122
+ },
123
+ k
124
+ );
125
+ }),
126
+ count > 1 && /* @__PURE__ */ jsxs("div", { className: `${baseClass}__sr-only`, role: "status", children: [
127
+ "Card ",
128
+ index + 1,
129
+ " of ",
130
+ count
131
+ ] })
132
+ ]
133
+ }
134
+ );
135
+ }
136
+ );
137
+ CardStack.displayName = "CardStack";
138
+ export {
139
+ CardStack
140
+ };
@@ -110,3 +110,12 @@
110
110
  outline: 2px solid var(--color-action-primary-bg);
111
111
  outline-offset: 2px;
112
112
  }
113
+
114
+ /* Icons take the colour of the text around them. Explicit, not just
115
+ inherited by default: a consumer stylesheet that sets a colour on
116
+ .material-symbols-rounded itself (a common global default) would
117
+ otherwise override inheritance and detach the icon from the state
118
+ colours this component sets on its parents. */
119
+ .ds-carousel .material-symbols-rounded {
120
+ color: inherit;
121
+ }
@@ -1,14 +1,23 @@
1
1
  "use client";
2
2
  import { jsxs, jsx, Fragment } from "react/jsx-runtime";
3
- import React, { useState, useRef, useCallback, useEffect } from "react";
3
+ import React, { useState, useSyncExternalStore, useRef, useCallback, useEffect } from "react";
4
+ import { MOTION_AUTOPLAY_INTERVAL_MS } from "../../tokens/motion.js";
4
5
  import "./Carousel.css";
5
6
  import "../../fonts/material-symbols.css";
7
+ const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
8
+ const subscribeReducedMotion = (callback) => {
9
+ const query = window.matchMedia(REDUCED_MOTION_QUERY);
10
+ query.addEventListener("change", callback);
11
+ return () => query.removeEventListener("change", callback);
12
+ };
13
+ const getReducedMotion = () => window.matchMedia(REDUCED_MOTION_QUERY).matches;
14
+ const getServerReducedMotion = () => false;
6
15
  const Carousel = ({
7
16
  children,
8
17
  showDots = true,
9
18
  showArrows = true,
10
19
  autoPlay = false,
11
- autoPlayInterval = 5e3,
20
+ autoPlayInterval = MOTION_AUTOPLAY_INTERVAL_MS,
12
21
  loop = false,
13
22
  onSlideChange,
14
23
  className = ""
@@ -17,6 +26,11 @@ const Carousel = ({
17
26
  const slideCount = slides.length;
18
27
  const [activeIndex, setActiveIndex] = useState(0);
19
28
  const [paused, setPaused] = useState(false);
29
+ const reducedMotion = useSyncExternalStore(
30
+ subscribeReducedMotion,
31
+ getReducedMotion,
32
+ getServerReducedMotion
33
+ );
20
34
  const containerRef = useRef(null);
21
35
  const baseClass = "ds-carousel";
22
36
  const goTo = useCallback(
@@ -35,10 +49,10 @@ const Carousel = ({
35
49
  const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]);
36
50
  const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]);
37
51
  useEffect(() => {
38
- if (!autoPlay || paused || slideCount <= 1) return;
52
+ if (!autoPlay || paused || reducedMotion || slideCount <= 1) return;
39
53
  const interval = setInterval(goNext, autoPlayInterval);
40
54
  return () => clearInterval(interval);
41
- }, [autoPlay, paused, autoPlayInterval, goNext, slideCount]);
55
+ }, [autoPlay, paused, reducedMotion, autoPlayInterval, goNext, slideCount]);
42
56
  const handleKeyDown = (e) => {
43
57
  if (e.key === "ArrowLeft") {
44
58
  e.preventDefault();
@@ -48,6 +62,21 @@ const Carousel = ({
48
62
  goNext();
49
63
  }
50
64
  };
65
+ const handleDotKeyDown = (e, index) => {
66
+ let target;
67
+ if (e.key === "ArrowRight") {
68
+ target = loop ? (index + 1) % slideCount : Math.min(index + 1, slideCount - 1);
69
+ } else if (e.key === "ArrowLeft") {
70
+ target = loop ? (index - 1 + slideCount) % slideCount : Math.max(index - 1, 0);
71
+ }
72
+ if (target === void 0) return;
73
+ e.preventDefault();
74
+ e.stopPropagation();
75
+ goTo(target);
76
+ const dotList = e.target.parentElement;
77
+ const dots = dotList?.querySelectorAll(`.${baseClass}__dot`);
78
+ dots?.[target]?.focus();
79
+ };
51
80
  const canGoPrev = loop || activeIndex > 0;
52
81
  const canGoNext = loop || activeIndex < slideCount - 1;
53
82
  const containerClasses = [baseClass, className].filter(Boolean).join(" ");
@@ -59,6 +88,8 @@ const Carousel = ({
59
88
  ref: containerRef,
60
89
  onMouseEnter: () => setPaused(true),
61
90
  onMouseLeave: () => setPaused(false),
91
+ onFocus: () => setPaused(true),
92
+ onBlur: () => setPaused(false),
62
93
  onKeyDown: handleKeyDown,
63
94
  role: "region",
64
95
  "aria-roledescription": "carousel",
@@ -111,8 +142,10 @@ const Carousel = ({
111
142
  {
112
143
  className: `${baseClass}__dot${index === activeIndex ? ` ${baseClass}__dot--active` : ""}`,
113
144
  onClick: () => goTo(index),
145
+ onKeyDown: (e) => handleDotKeyDown(e, index),
114
146
  role: "tab",
115
147
  "aria-selected": index === activeIndex,
148
+ tabIndex: index === activeIndex ? 0 : -1,
116
149
  "aria-label": `Go to slide ${index + 1}`,
117
150
  type: "button"
118
151
  },
@@ -1,6 +1,7 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useCallback } from "react";
3
3
  import { ResponsiveContainer, AreaChart as AreaChart$1, CartesianGrid, XAxis, YAxis, Tooltip, Area, Legend } from "recharts";
4
+ import { getChartSeriesColors } from "./palette.js";
4
5
  import "./Chart.css";
5
6
  function getCSSVar(name, fallback) {
6
7
  if (typeof window === "undefined") return fallback;
@@ -32,7 +33,7 @@ const AreaChart = ({
32
33
  const classes = [baseClass, className].filter(Boolean).join(" ");
33
34
  const textSecondary = getCSSVar("--color-text-secondary", "#A2A2A2");
34
35
  const gridColor = getCSSVar("--color-divider", "#232323");
35
- const defaultColor = getCSSVar("--color-action-primary-bg", "#0E6E8F");
36
+ const seriesColors = getChartSeriesColors();
36
37
  const renderTooltip = useCallback(
37
38
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
38
39
  (props) => /* @__PURE__ */ jsx(AreaTooltip, { ...props }),
@@ -50,8 +51,8 @@ const AreaChart = ({
50
51
  ] }, i)) })
51
52
  ] }),
52
53
  /* @__PURE__ */ jsx("div", { className: `${baseClass}__body`, children: /* @__PURE__ */ jsx(ResponsiveContainer, { width: "100%", height, children: /* @__PURE__ */ jsxs(AreaChart$1, { data, margin: { top: 8, right: 4, bottom: 0, left: -12 }, children: [
53
- /* @__PURE__ */ jsx("defs", { children: series.map((s) => {
54
- const color = s.color || defaultColor;
54
+ /* @__PURE__ */ jsx("defs", { children: series.map((s, i) => {
55
+ const color = s.color || seriesColors[i % seriesColors.length];
55
56
  return /* @__PURE__ */ jsxs("linearGradient", { id: `area-gradient-${s.dataKey}`, x1: "0", y1: "0", x2: "0", y2: "1", children: [
56
57
  /* @__PURE__ */ jsx("stop", { offset: "0%", stopColor: color, stopOpacity: 0.4 }),
57
58
  /* @__PURE__ */ jsx("stop", { offset: "100%", stopColor: color, stopOpacity: 0.05 })
@@ -76,13 +77,13 @@ const AreaChart = ({
76
77
  }
77
78
  ),
78
79
  /* @__PURE__ */ jsx(Tooltip, { content: renderTooltip }),
79
- series.map((s) => /* @__PURE__ */ jsx(
80
+ series.map((s, i) => /* @__PURE__ */ jsx(
80
81
  Area,
81
82
  {
82
83
  type: "monotone",
83
84
  dataKey: s.dataKey,
84
85
  name: s.label,
85
- stroke: s.color || defaultColor,
86
+ stroke: s.color || seriesColors[i % seriesColors.length],
86
87
  strokeWidth: 2,
87
88
  fill: `url(#area-gradient-${s.dataKey})`,
88
89
  fillOpacity: s.fillOpacity ?? 1,