rift-ds 1.4.0 → 1.6.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 (66) hide show
  1. package/README.md +3 -3
  2. package/components/AppSidebar/AppSidebar.css +6 -0
  3. package/components/AppSidebar/AppSidebar.d.ts +12 -2
  4. package/components/AppSidebar/AppSidebar.js +11 -1
  5. package/components/ContextMenu/ContextMenu.css +25 -0
  6. package/components/ContextMenu/ContextMenu.js +14 -6
  7. package/components/DropdownMenu/DropdownMenu.css +29 -0
  8. package/components/DropdownMenu/DropdownMenu.d.ts +2 -0
  9. package/components/DropdownMenu/DropdownMenu.js +15 -7
  10. package/components/PixelAvatar/PixelAvatar.css +79 -0
  11. package/components/PixelAvatar/PixelAvatar.d.ts +47 -0
  12. package/components/PixelAvatar/PixelAvatar.js +90 -0
  13. package/components/PixelAvatar/pixelInks.d.ts +27 -0
  14. package/components/PixelAvatar/pixelInks.js +45 -0
  15. package/components/PromptSuggestions/PromptSuggestions.css +218 -0
  16. package/components/PromptSuggestions/PromptSuggestions.d.ts +10 -3
  17. package/components/PromptSuggestions/PromptSuggestions.js +26 -3
  18. package/components/Quote/Quote.css +2 -2
  19. package/components/ShaderField/useShaderField.d.ts +4 -4
  20. package/components/SourceChip/SourceChip.css +211 -11
  21. package/components/SourceChip/SourceChip.d.ts +33 -10
  22. package/components/SourceChip/SourceChip.js +174 -22
  23. package/components/Stat/Stat.css +2 -2
  24. package/components/ThreadPanel/ThreadPanel.css +6 -0
  25. package/components/ThreadPanel/ThreadPanel.d.ts +3 -1
  26. package/components/ThreadPanel/ThreadPanel.js +22 -4
  27. package/components/Widget/Widget.css +207 -0
  28. package/components/Widget/Widget.d.ts +83 -0
  29. package/components/Widget/Widget.js +112 -0
  30. package/components/registry.json +17 -1
  31. package/components/registry.json.d.ts +17 -1
  32. package/components/registry.json.js +1 -1
  33. package/fonts/presets/fredoka-300-700-latin-ext.woff2 +0 -0
  34. package/fonts/presets/fredoka-300-700-latin.woff2 +0 -0
  35. package/fonts/presets/manrope-300-800-latin-ext.woff2 +0 -0
  36. package/fonts/presets/manrope-300-800-latin.woff2 +0 -0
  37. package/fonts/presets/nunito-300-800-latin-ext.woff2 +0 -0
  38. package/fonts/presets/nunito-300-800-latin.woff2 +0 -0
  39. package/fonts/presets/playfair-display-400-800-latin-ext.woff2 +0 -0
  40. package/fonts/presets/playfair-display-400-800-latin.woff2 +0 -0
  41. package/fonts/presets/plus-jakarta-sans-300-800-latin-ext.woff2 +0 -0
  42. package/fonts/presets/plus-jakarta-sans-300-800-latin.woff2 +0 -0
  43. package/fonts/presets/source-sans-3-300-700-latin.woff2 +0 -0
  44. package/index.d.ts +2 -0
  45. package/index.js +10 -0
  46. package/package.json +1 -1
  47. package/tokens/presets/contrast.css +9 -9
  48. package/tokens/presets/coral.css +6 -6
  49. package/tokens/presets/forest.css +10 -10
  50. package/tokens/presets/gold.css +119 -11
  51. package/tokens/presets/mono.css +2 -2
  52. package/tokens/presets/pink.css +104 -24
  53. package/tokens/presets/terminal.css +13 -13
  54. package/tokens/presets/violet.css +114 -26
  55. package/tokens/presets/warm.css +10 -10
  56. package/tokens/presets/zest.css +10 -10
  57. package/tokens/registry.json +6 -0
  58. package/tokens/registry.json.d.ts +6 -0
  59. package/tokens/registry.json.js +1 -1
  60. package/tokens/tokens-dark.css +9 -0
  61. package/tokens/tokens-light.css +14 -0
  62. package/tokens/tokens-typography.css +1 -1
  63. package/fonts/presets/fraunces-300-700-latin-ext.woff2 +0 -0
  64. package/fonts/presets/fraunces-300-700-latin.woff2 +0 -0
  65. package/fonts/presets/work-sans-300-700-latin-ext.woff2 +0 -0
  66. package/fonts/presets/work-sans-300-700-latin.woff2 +0 -0
package/README.md CHANGED
@@ -16,7 +16,7 @@ An open source React design system built for AI products and coding agents: comp
16
16
  - **One package, zero dependencies.** React is the only required peer dependency; recharts is optional and only for the charts entry. No configuration API, no providers, no build-tool integration: theming is plain CSS custom properties, so it works in any bundler, in any stack, beside anything you already use.
17
17
  - **Free, open source, consumed your way.** MIT end to end, no pro tier, no paywalled components. Install the npm package, pull single components as source through the shadcn CLI, or clone the repo and own every line; all three are first-class paths.
18
18
  - **A few primitives re-theme everything.** Every semantic token chains to a primitive, so a handful of overrides cascade through every component, the charts, and the ambient background, in both colour modes. Your brand is a short CSS block, not a fork.
19
- - **The shipped themes prove it.** Each one is the same system under different primitives, saved as a complete look: colour modes, a type pairing with self-hosted fonts, radius, density, motion, elevation, the chart palette. Setting `data-brand` on the root element applies all of it with zero runtime JavaScript, the build holds every theme's action colours to WCAG AA in both modes, and the playground builds yours the same way.
19
+ - **The shipped themes prove it.** Each one is the same system under different primitives, saved as a complete look: colour modes, a type pairing with self-hosted fonts, type weight and tracking, icon weight, radius, density, motion, elevation, the chart palette. Setting `data-brand` on the root element applies all of it with zero runtime JavaScript, the build holds every theme's action colours to WCAG AA in both modes, and the playground builds yours the same way.
20
20
  - **The AI product set is in the box.** Composer, streaming chat thread, tool calls, reasoning, thread panel and tabs: the components an assistant UI needs, the same set this repo's own site chat is built from.
21
21
  - **Legible to coding agents.** One command installs the agent skill and prints the MCP connect line, and the MCP tools and per-component markdown are generated from the same JSDoc that builds the shipped `.d.ts`: an agent reads the exact contract npm ships, no key, no account.
22
22
  - **Docs that cannot lie.** Every count, list, and prop table is generated from source registries and build-validated; CI fails on drift, and every component story ships through an accessibility audit. What the docs say is what ships.
@@ -73,13 +73,13 @@ import 'rift-ds/tokens/presets/presets.css';
73
73
 
74
74
  ## Components
75
75
 
76
- <!-- component-count -->147<!-- /component-count --> components, including a chat set for AI products (Chat thread, Composer, Tool call, Reasoning, Thread panel) and Shader field, a WebGL2 ambient background whose light sources read your colour tokens at runtime, so it re-themes with everything else. Details and live examples are on the [docs site](https://rift-ds.com/components).
76
+ <!-- component-count -->149<!-- /component-count --> components, including a chat set for AI products (Chat thread, Composer, Tool call, Reasoning, Thread panel) and Shader field, a WebGL2 ambient background whose light sources read your colour tokens at runtime, so it re-themes with everything else. Details and live examples are on the [docs site](https://rift-ds.com/components).
77
77
 
78
78
  <details>
79
79
  <summary>The full list</summary>
80
80
 
81
81
  <!-- component-list:start -->
82
- Accordion · Agent plan · Agent rail · Agent status · AI button · Alert · Alert dialog · Anchor nav · Animated number · App layout · App sidebar · Area chart · Attachment drop zone · Attachment group · Attachment tile · Attachment viewer · Avatar · Avatar group · Badge · Banner · Bar chart · Bottom nav · 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 · Gantt chart · Gauge · Globe · Hover card · Image compare · Input · Inspector colour picker · Inspector dropdown · Inspector input · Inspector section · Inspector segments · Inspector slider · Inspector toggle switch · Instructions · Interrupt card · Kbd · Legend tile · Lightbox · 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 · Rich dropdown · Scatter chart · Section title · Segmented control · Selection card · Shader field · Sidebar panel · Skeleton · Slider · Source chip · Source trail · Sparkline · Spinner · Split button · Split pane · Stacked bar chart · Stat · Status dot · Stepper · Streaming text · Swatch · Table · Tabs · Tag input · Textarea · Thread panel · Thread tabs · Time picker · Timeline · Toast · Toggle group · Toggle switch · Tool call · Toolbar · Tooltip · Top app bar · Tree view · Treemap · Usage card · Waveform · World map
82
+ Accordion · Agent plan · Agent rail · Agent status · AI button · Alert · Alert dialog · Anchor nav · Animated number · App layout · App sidebar · Area chart · Attachment drop zone · Attachment group · Attachment tile · Attachment viewer · Avatar · Avatar group · Badge · Banner · Bar chart · Bottom nav · 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 · Gantt chart · Gauge · Globe · Hover card · Image compare · Input · Inspector colour picker · Inspector dropdown · Inspector input · Inspector section · Inspector segments · Inspector slider · Inspector toggle switch · Instructions · Interrupt card · Kbd · Legend tile · Lightbox · 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 · Pixel avatar · Popover · Progress bar · Prompt suggestions · Prose · Quote · Radar chart · Radial chart · Radio button · Rating · Reasoning · Rich dropdown · Scatter chart · Section title · Segmented control · Selection card · Shader field · Sidebar panel · Skeleton · Slider · Source chip · Source trail · Sparkline · Spinner · Split button · Split pane · Stacked bar chart · Stat · Status dot · Stepper · Streaming text · Swatch · Table · Tabs · Tag input · Textarea · Thread panel · Thread tabs · Time picker · Timeline · Toast · Toggle group · Toggle switch · Tool call · Toolbar · Tooltip · Top app bar · Tree view · Treemap · Usage card · Waveform · Widget · World map
83
83
  <!-- component-list:end -->
84
84
 
85
85
  </details>
@@ -227,6 +227,12 @@
227
227
  flex-shrink: 0;
228
228
  }
229
229
 
230
+ /* A generated avatar in the icon seat: a block-level sprite, so the seat
231
+ lays it out as a box rather than on the text baseline. */
232
+ .ds-app-sidebar__btn-icon--avatar {
233
+ display: inline-flex;
234
+ }
235
+
230
236
  .ds-app-sidebar__btn-label {
231
237
  font-family: var(--font-paragraph-emphasis-family);
232
238
  font-size: var(--font-paragraph-emphasis-size);
@@ -23,8 +23,18 @@ export interface AppSidebarSubItem {
23
23
  export interface AppSidebarItem {
24
24
  /** Unique key for this item */
25
25
  key: string;
26
- /** Item icon — Material Symbol name (string) or custom element (ReactNode) */
27
- icon: string | React.ReactNode;
26
+ /** Item icon — Material Symbol name (string) or custom element (ReactNode). Optional only when `pixelAvatar` draws the mark instead */
27
+ icon?: string | React.ReactNode;
28
+ /**
29
+ * Draws a generated PixelAvatar in the icon seat instead of `icon`, for
30
+ * rows that name a thing rather than a place — projects, workspaces,
31
+ * accounts. `true` seeds the character from `label`; a string seeds it
32
+ * from that value, so a renamed item can keep its character by passing a
33
+ * stable id. The sidebar spreads the inks across all of its avatar rows,
34
+ * so neighbours differ in colour as well as shape. The character is
35
+ * decorative: the label beside it is the name
36
+ */
37
+ pixelAvatar?: boolean | string;
28
38
  /** Display label (shown when expanded) */
29
39
  label: string;
30
40
  /** Click handler — also fires on a link item, so a consumer can route client-side */
@@ -5,9 +5,11 @@ import { useLayer } from "../../behaviors/useLayer.js";
5
5
  import { useFocusScope } from "../../behaviors/useFocusScope.js";
6
6
  import { useScrollLock } from "../../behaviors/useScrollLock.js";
7
7
  import { Avatar } from "../Avatar/Avatar.js";
8
+ import { PixelAvatar } from "../PixelAvatar/PixelAvatar.js";
8
9
  import { SidebarPanel } from "../SidebarPanel/SidebarPanel.js";
9
10
  import "./AppSidebar.css";
10
11
  import "../../fonts/material-symbols.css";
12
+ import { distinctPixelInks } from "../PixelAvatar/pixelInks.js";
11
13
  function DefaultLogo() {
12
14
  return /* @__PURE__ */ jsxs(
13
15
  "svg",
@@ -49,6 +51,14 @@ const AppSidebar = ({
49
51
  showMobileTrigger = true
50
52
  }) => {
51
53
  const baseClass = "ds-app-sidebar";
54
+ const avatarSeed = (item) => typeof item.pixelAvatar === "string" ? item.pixelAvatar : item.label;
55
+ const avatarItems = sections.flatMap(
56
+ (section) => section.items.filter((item) => item.pixelAvatar)
57
+ );
58
+ const avatarInks = /* @__PURE__ */ new Map();
59
+ distinctPixelInks(avatarItems.map(avatarSeed)).forEach((ink, index) => {
60
+ avatarInks.set(avatarItems[index].key, ink);
61
+ });
52
62
  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
53
63
  const isExpanded = controlledExpanded ?? internalExpanded;
54
64
  const onExpandedChangeRef = useRef(onExpandedChange);
@@ -281,7 +291,7 @@ const AppSidebar = ({
281
291
  ].filter(Boolean).join(" ");
282
292
  const itemContent = /* @__PURE__ */ jsxs(Fragment, { children: [
283
293
  /* @__PURE__ */ jsxs("span", { className: `${baseClass}__btn-left`, children: [
284
- /* @__PURE__ */ jsx(
294
+ item.pixelAvatar ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__btn-icon ${baseClass}__btn-icon--avatar`, children: /* @__PURE__ */ jsx(PixelAvatar, { name: avatarSeed(item), ink: avatarInks.get(item.key) }) }) : /* @__PURE__ */ jsx(
285
295
  "span",
286
296
  {
287
297
  className: `${baseClass}__btn-icon${typeof item.icon === "string" ? " material-symbols-rounded" : ""}`,
@@ -114,6 +114,31 @@
114
114
  background-color: var(--color-action-passive-bg-hover);
115
115
  }
116
116
 
117
+ /* States — selected: the current choice of a set. DropdownMenu.css owns
118
+ the recipe (passive wash held, full-strength text and icon, trailing
119
+ check); the two menus share the entry model, so they share the look. */
120
+ .ds-context-menu__item--selected {
121
+ background-color: var(--color-action-passive-bg-hover);
122
+ color: var(--color-text-primary);
123
+ }
124
+
125
+ .ds-context-menu__item--selected:hover,
126
+ .ds-context-menu__item--selected.ds-context-menu__item--focused {
127
+ background-color: var(--color-action-passive-bg-active);
128
+ }
129
+
130
+ .ds-context-menu__item--selected .ds-context-menu__item-icon {
131
+ color: var(--color-icon-primary);
132
+ }
133
+
134
+ .ds-context-menu__check {
135
+ --icon-size: var(--icon-size-500);
136
+ color: var(--color-icon-primary);
137
+ margin-left: auto;
138
+ flex-shrink: 0;
139
+ line-height: 1;
140
+ }
141
+
117
142
  /* States — disabled */
118
143
  .ds-context-menu__item--disabled {
119
144
  opacity: 0.4;
@@ -121,14 +121,17 @@ const SubEntry = ({
121
121
  const itemClasses = [
122
122
  `${baseClass}__item`,
123
123
  entry.disabled ? `${baseClass}__item--disabled` : "",
124
- entry.destructive ? `${baseClass}__item--destructive` : ""
124
+ entry.destructive ? `${baseClass}__item--destructive` : "",
125
+ entry.selected ? `${baseClass}__item--selected` : ""
125
126
  ].filter(Boolean).join(" ");
127
+ const isChoice = entry.selected !== void 0;
126
128
  return /* @__PURE__ */ jsxs(
127
129
  "li",
128
130
  {
129
131
  id: itemId,
130
132
  className: itemClasses,
131
- role: "menuitem",
133
+ role: isChoice ? "menuitemradio" : "menuitem",
134
+ "aria-checked": isChoice ? entry.selected : void 0,
132
135
  "aria-disabled": entry.disabled || void 0,
133
136
  onClick: (e) => {
134
137
  e.stopPropagation();
@@ -142,7 +145,8 @@ const SubEntry = ({
142
145
  entry.icon && /* @__PURE__ */ jsx("span", { className: `${baseClass}__item-icon material-symbols-rounded`, "aria-hidden": "true", children: entry.icon }),
143
146
  entry.label
144
147
  ] }),
145
- entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut })
148
+ entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut }),
149
+ entry.selected && /* @__PURE__ */ jsx("span", { className: `${baseClass}__check material-symbols-rounded`, "aria-hidden": "true", children: "check" })
146
150
  ]
147
151
  }
148
152
  );
@@ -321,14 +325,17 @@ const ContextMenu = React.forwardRef(
321
325
  `${baseClass}__item`,
322
326
  isFocused ? `${baseClass}__item--focused` : "",
323
327
  entry.disabled ? `${baseClass}__item--disabled` : "",
324
- entry.destructive ? `${baseClass}__item--destructive` : ""
328
+ entry.destructive ? `${baseClass}__item--destructive` : "",
329
+ entry.selected ? `${baseClass}__item--selected` : ""
325
330
  ].filter(Boolean).join(" ");
331
+ const isChoice = entry.selected !== void 0;
326
332
  return /* @__PURE__ */ jsxs(
327
333
  "li",
328
334
  {
329
335
  id: `${generatedId}-item-${myIndex}`,
330
336
  className: itemClasses,
331
- role: "menuitem",
337
+ role: isChoice ? "menuitemradio" : "menuitem",
338
+ "aria-checked": isChoice ? entry.selected : void 0,
332
339
  "aria-disabled": entry.disabled || void 0,
333
340
  onClick: (e) => {
334
341
  e.stopPropagation();
@@ -349,7 +356,8 @@ const ContextMenu = React.forwardRef(
349
356
  ),
350
357
  entry.label
351
358
  ] }),
352
- entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut })
359
+ entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut }),
360
+ entry.selected && /* @__PURE__ */ jsx("span", { className: `${baseClass}__check material-symbols-rounded`, "aria-hidden": "true", children: "check" })
353
361
  ]
354
362
  },
355
363
  `item-${index}`
@@ -145,6 +145,35 @@
145
145
  background-color: var(--color-action-passive-bg-hover);
146
146
  }
147
147
 
148
+ /* ============================================
149
+ SELECTED ITEM
150
+ The current choice of a set: the passive wash held down, text and icon
151
+ at full strength, and a trailing check — three cues, none of them the
152
+ action colour, which a menu row has no claim on.
153
+ ============================================ */
154
+
155
+ .ds-dropdown-menu__item--selected {
156
+ background-color: var(--color-action-passive-bg-hover);
157
+ color: var(--color-text-primary);
158
+ }
159
+
160
+ .ds-dropdown-menu__item--selected:hover,
161
+ .ds-dropdown-menu__item--selected.ds-dropdown-menu__item--focused {
162
+ background-color: var(--color-action-passive-bg-active);
163
+ }
164
+
165
+ .ds-dropdown-menu__item--selected .ds-dropdown-menu__item-icon {
166
+ color: var(--color-icon-primary);
167
+ }
168
+
169
+ .ds-dropdown-menu__check {
170
+ --icon-size: var(--icon-size-500);
171
+ color: var(--color-icon-primary);
172
+ margin-left: auto;
173
+ flex-shrink: 0;
174
+ line-height: 1;
175
+ }
176
+
148
177
  /* ============================================
149
178
  DISABLED ITEM
150
179
  ============================================ */
@@ -11,6 +11,8 @@ export interface DropdownMenuItem {
11
11
  disabled?: boolean;
12
12
  /** Destructive / danger styling (red text) for delete, remove, etc. */
13
13
  destructive?: boolean;
14
+ /** Marks the item as the current choice of a set: it holds the passive wash and a trailing check, and is announced as a checked radio item. Leave undefined on plain action items; pass `false` on the unchosen members of a set so they are announced as radio items too. Ignored on an item that opens a sub-menu. */
15
+ selected?: boolean;
14
16
  /** Click handler */
15
17
  onClick?: () => void;
16
18
  /** Nested sub-menu items */
@@ -120,14 +120,17 @@ const MenuPanel = ({
120
120
  const itemClasses = [
121
121
  `${baseClass}__item`,
122
122
  entry.disabled ? `${baseClass}__item--disabled` : "",
123
- entry.destructive ? `${baseClass}__item--destructive` : ""
123
+ entry.destructive ? `${baseClass}__item--destructive` : "",
124
+ entry.selected ? `${baseClass}__item--selected` : ""
124
125
  ].filter(Boolean).join(" ");
126
+ const isChoice = entry.selected !== void 0;
125
127
  return /* @__PURE__ */ jsxs(
126
128
  "li",
127
129
  {
128
130
  id: entryId,
129
131
  className: itemClasses,
130
- role: "menuitem",
132
+ role: isChoice ? "menuitemradio" : "menuitem",
133
+ "aria-checked": isChoice ? entry.selected : void 0,
131
134
  "aria-disabled": entry.disabled || void 0,
132
135
  onClick: (e) => {
133
136
  e.stopPropagation();
@@ -141,7 +144,8 @@ const MenuPanel = ({
141
144
  entry.icon && (typeof entry.icon === "string" ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__item-icon material-symbols-rounded`, "aria-hidden": "true", children: entry.icon }) : /* @__PURE__ */ jsx("span", { className: `${baseClass}__item-icon`, "aria-hidden": "true", children: entry.icon })),
142
145
  entry.label
143
146
  ] }),
144
- entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut })
147
+ entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut }),
148
+ entry.selected && /* @__PURE__ */ jsx("span", { className: `${baseClass}__check material-symbols-rounded`, "aria-hidden": "true", children: "check" })
145
149
  ]
146
150
  },
147
151
  `item-${index}`
@@ -253,7 +257,7 @@ const DropdownMenu = ({
253
257
  };
254
258
  useEffect(() => {
255
259
  if (isOpen && focusedIndex >= 0 && panelRef.current) {
256
- const allItems = panelRef.current.querySelectorAll(`[role="menuitem"]`);
260
+ const allItems = panelRef.current.querySelectorAll(`[role="menuitem"], [role="menuitemradio"]`);
257
261
  const el = allItems[focusedIndex];
258
262
  el?.scrollIntoView({ block: "nearest" });
259
263
  }
@@ -291,14 +295,17 @@ const DropdownMenu = ({
291
295
  `${baseClass}__item`,
292
296
  isFocused ? `${baseClass}__item--focused` : "",
293
297
  entry.disabled ? `${baseClass}__item--disabled` : "",
294
- entry.destructive ? `${baseClass}__item--destructive` : ""
298
+ entry.destructive ? `${baseClass}__item--destructive` : "",
299
+ entry.selected ? `${baseClass}__item--selected` : ""
295
300
  ].filter(Boolean).join(" ");
301
+ const isChoice = entry.selected !== void 0;
296
302
  return /* @__PURE__ */ jsxs(
297
303
  "li",
298
304
  {
299
305
  id: `${generatedId}-item-${myIndex}`,
300
306
  className: itemClasses,
301
- role: "menuitem",
307
+ role: isChoice ? "menuitemradio" : "menuitem",
308
+ "aria-checked": isChoice ? entry.selected : void 0,
302
309
  "aria-disabled": entry.disabled || void 0,
303
310
  onClick: (e) => {
304
311
  e.stopPropagation();
@@ -312,7 +319,8 @@ const DropdownMenu = ({
312
319
  entry.icon && (typeof entry.icon === "string" ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__item-icon material-symbols-rounded`, "aria-hidden": "true", children: entry.icon }) : /* @__PURE__ */ jsx("span", { className: `${baseClass}__item-icon`, "aria-hidden": "true", children: entry.icon })),
313
320
  entry.label
314
321
  ] }),
315
- entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut })
322
+ entry.shortcut && /* @__PURE__ */ jsx("span", { className: `${baseClass}__shortcut`, children: entry.shortcut }),
323
+ entry.selected && /* @__PURE__ */ jsx("span", { className: `${baseClass}__check material-symbols-rounded`, "aria-hidden": "true", children: "check" })
316
324
  ]
317
325
  },
318
326
  `item-${index}`
@@ -0,0 +1,79 @@
1
+ /* ============================================
2
+ PIXEL AVATAR COMPONENT
3
+ A generated character mark, drawn from a name
4
+ ============================================ */
5
+
6
+ /* Base — the character rides the icon scale, so it seats wherever an icon
7
+ does. A host that already sets --icon-size on its icon seat is matched by
8
+ the size classes below, which set the same variable. */
9
+
10
+ .ds-pixel-avatar {
11
+ display: inline-flex;
12
+ flex-shrink: 0;
13
+ box-sizing: border-box;
14
+ width: var(--icon-size);
15
+ height: var(--icon-size);
16
+ color: var(--ds-pixel-avatar-ink);
17
+ }
18
+
19
+ .ds-pixel-avatar__sprite {
20
+ display: block;
21
+ width: 100%;
22
+ height: 100%;
23
+ fill: currentColor;
24
+ }
25
+
26
+ /* Sizes */
27
+
28
+ .ds-pixel-avatar--sm {
29
+ --icon-size: var(--icon-size-500);
30
+ }
31
+
32
+ .ds-pixel-avatar--md {
33
+ --icon-size: var(--icon-size-600);
34
+ }
35
+
36
+ .ds-pixel-avatar--lg {
37
+ --icon-size: var(--icon-size-800);
38
+ }
39
+
40
+ .ds-pixel-avatar--xl {
41
+ --icon-size: var(--icon-size-1200);
42
+ }
43
+
44
+ /* Inks — one rule per pixel ink token. The tokens carry a light and a dark
45
+ value each, so the character holds its contrast in both themes. */
46
+
47
+ .ds-pixel-avatar--ink-1 {
48
+ --ds-pixel-avatar-ink: var(--color-pixel-ink-1);
49
+ }
50
+
51
+ .ds-pixel-avatar--ink-2 {
52
+ --ds-pixel-avatar-ink: var(--color-pixel-ink-2);
53
+ }
54
+
55
+ .ds-pixel-avatar--ink-3 {
56
+ --ds-pixel-avatar-ink: var(--color-pixel-ink-3);
57
+ }
58
+
59
+ .ds-pixel-avatar--ink-4 {
60
+ --ds-pixel-avatar-ink: var(--color-pixel-ink-4);
61
+ }
62
+
63
+ .ds-pixel-avatar--ink-5 {
64
+ --ds-pixel-avatar-ink: var(--color-pixel-ink-5);
65
+ }
66
+
67
+ .ds-pixel-avatar--ink-6 {
68
+ --ds-pixel-avatar-ink: var(--color-pixel-ink-6);
69
+ }
70
+
71
+ /* Variants — the tile is the same sprite on a wash of its own ink. The
72
+ padding comes from the size, not a percentage, which would resolve against
73
+ the host's width rather than the tile's. */
74
+
75
+ .ds-pixel-avatar--tile {
76
+ padding: calc(var(--icon-size) / 16);
77
+ border-radius: var(--radius-200);
78
+ background-color: color-mix(in srgb, var(--ds-pixel-avatar-ink) 16%, transparent);
79
+ }
@@ -0,0 +1,47 @@
1
+ import { default as React } from 'react';
2
+ import { PixelInk } from './pixelInks';
3
+ export { distinctPixelInks, pixelAvatarInk } from './pixelInks';
4
+ export type { PixelInk } from './pixelInks';
5
+ /** Props owned by PixelAvatar itself — everything else falls through to the root span. */
6
+ type PixelAvatarOwnProps = {
7
+ /**
8
+ * The seed. The same name always draws the same character in the same
9
+ * ink, so a project, workspace or thread has its mark the moment it has a
10
+ * name, with nothing designed or stored for it.
11
+ */
12
+ name: string;
13
+ /** Rendered size, on the icon scale (`--icon-size-500/600/800/1200` — 20, 24, 32 and 48px), so the character seats wherever an icon does. */
14
+ size?: 'sm' | 'md' | 'lg' | 'xl';
15
+ /**
16
+ * Overrides the ink the name picks (1 to 6, the `--color-pixel-ink-*`
17
+ * tokens). A name's own ink is one of six, so two neighbours can land on
18
+ * the same one; a host drawing a list passes each row's entry from
19
+ * `distinctPixelInks` here to keep them apart. The character's shape still
20
+ * comes from the name.
21
+ */
22
+ ink?: PixelInk;
23
+ /** `plain` draws the character alone; `tile` seats it on a rounded wash of its own ink, for a larger standalone mark. */
24
+ variant?: 'plain' | 'tile';
25
+ /**
26
+ * Accessible name. Omit it when the mark sits beside the name it was
27
+ * drawn from, which is the usual case: the character is then decorative
28
+ * and hidden from assistive technology. Given, the root becomes an image
29
+ * with this label.
30
+ */
31
+ label?: string;
32
+ /** Additional CSS classes */
33
+ className?: string;
34
+ };
35
+ export interface PixelAvatarProps extends PixelAvatarOwnProps, Omit<React.ComponentPropsWithoutRef<'span'>, keyof PixelAvatarOwnProps> {
36
+ }
37
+ /**
38
+ * PixelAvatar — a generated character mark. The name is the seed: a small
39
+ * pixel creature and one of six inks are drawn from it, deterministically,
40
+ * so the same name is always the same character and nothing has to be
41
+ * designed, uploaded or stored. For the things that have a name before they
42
+ * have a logo — projects, workspaces, repositories, agents.
43
+ *
44
+ * Pure: no hooks, no random source and no browser API, so the server and the
45
+ * client draw the same sprite and it renders from a Server Component.
46
+ */
47
+ export declare const PixelAvatar: React.ForwardRefExoticComponent<PixelAvatarProps & React.RefAttributes<HTMLSpanElement>>;
@@ -0,0 +1,90 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { hashName, pixelAvatarInk } from "./pixelInks.js";
4
+ import { distinctPixelInks } from "./pixelInks.js";
5
+ import "./PixelAvatar.css";
6
+ const GRID = 8;
7
+ const MARGIN = 1;
8
+ const ROW_CHANCES = [
9
+ [0, 0.3, 0.25, 0.15],
10
+ // antennae
11
+ [0.1, 0.75, 1, 1],
12
+ // crown
13
+ [0.55, 1, 1, 1],
14
+ // head
15
+ [0.75, 1, 1, 1],
16
+ // eyes (one cell is carved out below)
17
+ [0.7, 1, 1, 1],
18
+ // cheeks
19
+ [0.35, 0.85, 0.9, 0.7],
20
+ // jaw
21
+ [0.45, 0.5, 0.45, 0.35],
22
+ // legs
23
+ [0.35, 0.3, 0.25, 0.1]
24
+ // feet
25
+ ];
26
+ const EYE_ROW = 3;
27
+ function seeded(seed) {
28
+ let state = seed;
29
+ return () => {
30
+ state = state + 1831565813 | 0;
31
+ let t = Math.imul(state ^ state >>> 15, 1 | state);
32
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
33
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
34
+ };
35
+ }
36
+ function spriteCells(name) {
37
+ const next = seeded(hashName(name));
38
+ const eyeColumn = next() < 0.6 ? 2 : 1;
39
+ const cells = [];
40
+ ROW_CHANCES.forEach((chances, row) => {
41
+ chances.forEach((chance, column) => {
42
+ if (next() >= chance) return;
43
+ if (row === EYE_ROW && column === eyeColumn) return;
44
+ cells.push([column, row], [GRID - 1 - column, row]);
45
+ });
46
+ });
47
+ return cells;
48
+ }
49
+ const PixelAvatar = React.forwardRef(
50
+ ({ name, size = "md", variant = "plain", ink: inkOverride, label, className = "", ...rest }, ref) => {
51
+ const baseClass = "ds-pixel-avatar";
52
+ const ink = inkOverride ?? pixelAvatarInk(name);
53
+ const classes = [
54
+ baseClass,
55
+ `${baseClass}--${size}`,
56
+ `${baseClass}--${variant}`,
57
+ `${baseClass}--ink-${ink}`,
58
+ className
59
+ ].filter(Boolean).join(" ");
60
+ const viewBox = `${-MARGIN} ${-MARGIN} ${GRID + MARGIN * 2} ${GRID + MARGIN * 2}`;
61
+ return /* @__PURE__ */ jsx(
62
+ "span",
63
+ {
64
+ ...rest,
65
+ ref,
66
+ className: classes,
67
+ role: label ? "img" : void 0,
68
+ "aria-label": label,
69
+ "aria-hidden": label ? void 0 : true,
70
+ children: /* @__PURE__ */ jsx(
71
+ "svg",
72
+ {
73
+ className: `${baseClass}__sprite`,
74
+ viewBox,
75
+ shapeRendering: "crispEdges",
76
+ focusable: "false",
77
+ "aria-hidden": "true",
78
+ children: spriteCells(name).map(([column, row]) => /* @__PURE__ */ jsx("rect", { x: column, y: row, width: 1, height: 1 }, `${column}-${row}`))
79
+ }
80
+ )
81
+ }
82
+ );
83
+ }
84
+ );
85
+ PixelAvatar.displayName = "PixelAvatar";
86
+ export {
87
+ PixelAvatar,
88
+ distinctPixelInks,
89
+ pixelAvatarInk
90
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The ink side of PixelAvatar, kept apart from the component so a host that
3
+ * lays out a list can compute its inks without rendering anything. The
4
+ * component re-exports everything here; this module has no subpath of its
5
+ * own.
6
+ */
7
+ /** One of the six pixel inks, by token number. */
8
+ export type PixelInk = 1 | 2 | 3 | 4 | 5 | 6;
9
+ /** FNV-1a, 32-bit: a small stable hash of a name. */
10
+ export declare function hashName(name: string): number;
11
+ /** The ink a name picks for itself: stable, and one of six. */
12
+ export declare function pixelAvatarInk(name: string): PixelInk;
13
+ /**
14
+ * Inks for a list of names shown together, in order, so neighbours are told
15
+ * apart by colour as well as by shape. Each name keeps the ink it picks for
16
+ * itself when that ink is still among the least used in the list so far and
17
+ * sits clear of the row before it; otherwise it takes the next ink round the
18
+ * set that does. "Clear" means at least two steps away on the hue ring the
19
+ * inks are numbered round (red, orange, yellow, green, blue, purple), so a
20
+ * row never follows its own colour or the hue beside it: a muted theme
21
+ * draws neighbouring hues close together, and two warm tans in a row read
22
+ * as the same mark. The first six names therefore all differ, and a longer
23
+ * list repeats as evenly as it can. Deterministic
24
+ * for a given list, so server and client agree; a name's ink can change when
25
+ * the list around it does, which is the price of the guarantee.
26
+ */
27
+ export declare function distinctPixelInks(names: readonly string[]): PixelInk[];
@@ -0,0 +1,45 @@
1
+ const INKS = 6;
2
+ function hashName(name) {
3
+ let hash = 2166136261;
4
+ for (let i = 0; i < name.length; i += 1) {
5
+ hash ^= name.charCodeAt(i);
6
+ hash = Math.imul(hash, 16777619);
7
+ }
8
+ return hash >>> 0;
9
+ }
10
+ function pixelAvatarInk(name) {
11
+ return hashName(`${name}:colour`) % INKS + 1;
12
+ }
13
+ function distinctPixelInks(names) {
14
+ const used = new Array(INKS).fill(0);
15
+ const inks = [];
16
+ names.forEach((name) => {
17
+ const own = pixelAvatarInk(name) - 1;
18
+ const fewest = Math.min(...used);
19
+ const previous = inks.length > 0 ? inks[inks.length - 1] - 1 : -1;
20
+ let clear = -1;
21
+ let different = -1;
22
+ let any = -1;
23
+ for (let step = 0; step < INKS; step += 1) {
24
+ const candidate = (own + step) % INKS;
25
+ if (used[candidate] !== fewest) continue;
26
+ if (any === -1) any = candidate;
27
+ if (candidate === previous) continue;
28
+ if (different === -1) different = candidate;
29
+ const apart = Math.abs(candidate - previous);
30
+ if (previous === -1 || Math.min(apart, INKS - apart) >= 2) {
31
+ clear = candidate;
32
+ break;
33
+ }
34
+ }
35
+ const chosen = clear !== -1 ? clear : different !== -1 ? different : any;
36
+ used[chosen] += 1;
37
+ inks.push(chosen + 1);
38
+ });
39
+ return inks;
40
+ }
41
+ export {
42
+ distinctPixelInks,
43
+ hashName,
44
+ pixelAvatarInk
45
+ };