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.
- package/README.md +3 -3
- package/components/AppSidebar/AppSidebar.css +6 -0
- package/components/AppSidebar/AppSidebar.d.ts +12 -2
- package/components/AppSidebar/AppSidebar.js +11 -1
- package/components/ContextMenu/ContextMenu.css +25 -0
- package/components/ContextMenu/ContextMenu.js +14 -6
- package/components/DropdownMenu/DropdownMenu.css +29 -0
- package/components/DropdownMenu/DropdownMenu.d.ts +2 -0
- package/components/DropdownMenu/DropdownMenu.js +15 -7
- package/components/PixelAvatar/PixelAvatar.css +79 -0
- package/components/PixelAvatar/PixelAvatar.d.ts +47 -0
- package/components/PixelAvatar/PixelAvatar.js +90 -0
- package/components/PixelAvatar/pixelInks.d.ts +27 -0
- package/components/PixelAvatar/pixelInks.js +45 -0
- package/components/PromptSuggestions/PromptSuggestions.css +218 -0
- package/components/PromptSuggestions/PromptSuggestions.d.ts +10 -3
- package/components/PromptSuggestions/PromptSuggestions.js +26 -3
- package/components/Quote/Quote.css +2 -2
- package/components/ShaderField/useShaderField.d.ts +4 -4
- package/components/SourceChip/SourceChip.css +211 -11
- package/components/SourceChip/SourceChip.d.ts +33 -10
- package/components/SourceChip/SourceChip.js +174 -22
- package/components/Stat/Stat.css +2 -2
- package/components/ThreadPanel/ThreadPanel.css +6 -0
- package/components/ThreadPanel/ThreadPanel.d.ts +3 -1
- package/components/ThreadPanel/ThreadPanel.js +22 -4
- package/components/Widget/Widget.css +207 -0
- package/components/Widget/Widget.d.ts +83 -0
- package/components/Widget/Widget.js +112 -0
- package/components/registry.json +17 -1
- package/components/registry.json.d.ts +17 -1
- package/components/registry.json.js +1 -1
- package/fonts/presets/fredoka-300-700-latin-ext.woff2 +0 -0
- package/fonts/presets/fredoka-300-700-latin.woff2 +0 -0
- package/fonts/presets/manrope-300-800-latin-ext.woff2 +0 -0
- package/fonts/presets/manrope-300-800-latin.woff2 +0 -0
- package/fonts/presets/nunito-300-800-latin-ext.woff2 +0 -0
- package/fonts/presets/nunito-300-800-latin.woff2 +0 -0
- package/fonts/presets/playfair-display-400-800-latin-ext.woff2 +0 -0
- package/fonts/presets/playfair-display-400-800-latin.woff2 +0 -0
- package/fonts/presets/plus-jakarta-sans-300-800-latin-ext.woff2 +0 -0
- package/fonts/presets/plus-jakarta-sans-300-800-latin.woff2 +0 -0
- package/fonts/presets/source-sans-3-300-700-latin.woff2 +0 -0
- package/index.d.ts +2 -0
- package/index.js +10 -0
- package/package.json +1 -1
- package/tokens/presets/contrast.css +9 -9
- package/tokens/presets/coral.css +6 -6
- package/tokens/presets/forest.css +10 -10
- package/tokens/presets/gold.css +119 -11
- package/tokens/presets/mono.css +2 -2
- package/tokens/presets/pink.css +104 -24
- package/tokens/presets/terminal.css +13 -13
- package/tokens/presets/violet.css +114 -26
- package/tokens/presets/warm.css +10 -10
- package/tokens/presets/zest.css +10 -10
- package/tokens/registry.json +6 -0
- package/tokens/registry.json.d.ts +6 -0
- package/tokens/registry.json.js +1 -1
- package/tokens/tokens-dark.css +9 -0
- package/tokens/tokens-light.css +14 -0
- package/tokens/tokens-typography.css +1 -1
- package/fonts/presets/fraunces-300-700-latin-ext.woff2 +0 -0
- package/fonts/presets/fraunces-300-700-latin.woff2 +0 -0
- package/fonts/presets/work-sans-300-700-latin-ext.woff2 +0 -0
- 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 -->
|
|
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
|
|
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
|
+
};
|