rift-ds 1.5.0 → 1.7.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 (52) hide show
  1. package/README.md +8 -10
  2. package/bin/rift-ds.mjs +111 -37
  3. package/components/AgentRail/AgentRail.css +2 -2
  4. package/components/AppSidebar/AppSidebar.css +6 -0
  5. package/components/AppSidebar/AppSidebar.d.ts +12 -2
  6. package/components/AppSidebar/AppSidebar.js +11 -1
  7. package/components/Attachment/Attachment.css +14 -0
  8. package/components/Attachment/AttachmentGroup.d.ts +2 -2
  9. package/components/Attachment/AttachmentGroup.js +7 -2
  10. package/components/Composer/Composer.css +88 -4
  11. package/components/Composer/Composer.d.ts +21 -0
  12. package/components/Composer/Composer.js +199 -14
  13. package/components/Mention/Mention.css +195 -0
  14. package/components/Mention/Mention.d.ts +68 -0
  15. package/components/Mention/Mention.js +145 -0
  16. package/components/Mention/mentions.d.ts +81 -0
  17. package/components/Mention/mentions.js +83 -0
  18. package/components/PixelAvatar/PixelAvatar.css +79 -0
  19. package/components/PixelAvatar/PixelAvatar.d.ts +47 -0
  20. package/components/PixelAvatar/PixelAvatar.js +90 -0
  21. package/components/PixelAvatar/pixelInks.d.ts +27 -0
  22. package/components/PixelAvatar/pixelInks.js +45 -0
  23. package/components/PromptSuggestions/PromptSuggestions.css +218 -0
  24. package/components/PromptSuggestions/PromptSuggestions.d.ts +10 -3
  25. package/components/PromptSuggestions/PromptSuggestions.js +26 -3
  26. package/components/ShaderField/ShaderField.d.ts +3 -3
  27. package/components/SourceChip/SourceChip.css +211 -11
  28. package/components/SourceChip/SourceChip.d.ts +33 -10
  29. package/components/SourceChip/SourceChip.js +174 -22
  30. package/components/StatusDot/StatusDot.css +36 -13
  31. package/components/StatusDot/StatusDot.d.ts +16 -4
  32. package/components/StatusDot/StatusDot.js +25 -5
  33. package/components/ThreadPanel/ThreadPanel.css +26 -11
  34. package/components/ThreadPanel/ThreadPanel.d.ts +18 -4
  35. package/components/ThreadPanel/ThreadPanel.js +52 -10
  36. package/components/ThreadTabs/ThreadTabs.css +18 -8
  37. package/components/ThreadTabs/ThreadTabs.d.ts +8 -2
  38. package/components/ThreadTabs/ThreadTabs.js +24 -5
  39. package/components/Widget/Widget.css +207 -0
  40. package/components/Widget/Widget.d.ts +83 -0
  41. package/components/Widget/Widget.js +112 -0
  42. package/components/registry.json +26 -2
  43. package/components/registry.json.d.ts +26 -2
  44. package/components/registry.json.js +1 -1
  45. package/index.d.ts +3 -0
  46. package/index.js +21 -0
  47. package/package.json +5 -1
  48. package/tokens/registry.json +6 -0
  49. package/tokens/registry.json.d.ts +6 -0
  50. package/tokens/registry.json.js +1 -1
  51. package/tokens/tokens-dark.css +9 -0
  52. package/tokens/tokens-light.css +14 -0
@@ -0,0 +1,145 @@
1
+ "use client";
2
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
3
+ import React, { useRef, useLayoutEffect } from "react";
4
+ import "../../fonts/material-symbols.css";
5
+ import "./Mention.css";
6
+ import { mentionOptionId, mentionListboxId, splitMentions } from "./mentions.js";
7
+ import { filterMentionItems, getMentionQuery, insertMention } from "./mentions.js";
8
+ const Mention = React.forwardRef(
9
+ ({ trigger = "@", className = "", children, ...rest }, ref) => /* @__PURE__ */ jsxs(
10
+ "span",
11
+ {
12
+ ...rest,
13
+ ref,
14
+ className: ["ds-mention", className].filter(Boolean).join(" "),
15
+ "data-trigger": trigger,
16
+ children: [
17
+ /* @__PURE__ */ jsx("span", { className: "ds-mention__trigger", children: trigger }),
18
+ children
19
+ ]
20
+ }
21
+ )
22
+ );
23
+ Mention.displayName = "Mention";
24
+ const MentionText = React.forwardRef(
25
+ ({ text, sources, className = "", ...rest }, ref) => /* @__PURE__ */ jsx(
26
+ "span",
27
+ {
28
+ ...rest,
29
+ ref,
30
+ className: ["ds-mention-text", className].filter(Boolean).join(" "),
31
+ children: splitMentions(text, sources).map(
32
+ (segment, index) => segment.type === "mention" ? (
33
+ // Segments never reorder: the text is re-split whole on every change
34
+ /* @__PURE__ */ jsx(Mention, { trigger: segment.trigger, children: segment.item.label }, index)
35
+ ) : /* @__PURE__ */ jsx(React.Fragment, { children: segment.text }, index)
36
+ )
37
+ }
38
+ )
39
+ );
40
+ MentionText.displayName = "MentionText";
41
+ const renderLabel = (label, query) => {
42
+ const needle = query.trim().toLowerCase();
43
+ const at = needle === "" ? -1 : label.toLowerCase().indexOf(needle);
44
+ if (at < 0) return label;
45
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
46
+ label.slice(0, at),
47
+ /* @__PURE__ */ jsx("span", { className: "ds-mention-menu__match", children: label.slice(at, at + needle.length) }),
48
+ label.slice(at + needle.length)
49
+ ] });
50
+ };
51
+ const MentionMenu = React.forwardRef(
52
+ ({
53
+ items,
54
+ activeId,
55
+ onActiveChange,
56
+ onSelect,
57
+ query = "",
58
+ label = "Suggestions",
59
+ emptyText,
60
+ className = "",
61
+ id,
62
+ onMouseDown,
63
+ ...rest
64
+ }, ref) => {
65
+ const baseClass = "ds-mention-menu";
66
+ const generatedId = React.useId();
67
+ const menuId = id ?? generatedId;
68
+ const listRef = useRef(null);
69
+ useLayoutEffect(() => {
70
+ const list = listRef.current;
71
+ if (!list || activeId === void 0) return;
72
+ const row = list.querySelector('[aria-selected="true"]');
73
+ if (!row) return;
74
+ const top = row.offsetTop;
75
+ const bottom = top + row.offsetHeight;
76
+ if (top < list.scrollTop) list.scrollTop = top;
77
+ else if (bottom > list.scrollTop + list.clientHeight) {
78
+ list.scrollTop = bottom - list.clientHeight;
79
+ }
80
+ }, [activeId, items]);
81
+ if (items.length === 0 && !emptyText) return null;
82
+ const handleMouseDown = (event) => {
83
+ onMouseDown?.(event);
84
+ event.preventDefault();
85
+ };
86
+ return /* @__PURE__ */ jsx(
87
+ "div",
88
+ {
89
+ ...rest,
90
+ ref,
91
+ id: menuId,
92
+ className: [baseClass, query.trim() !== "" ? `${baseClass}--filtered` : "", className].filter(Boolean).join(" "),
93
+ onMouseDown: handleMouseDown,
94
+ children: items.length === 0 ? /* @__PURE__ */ jsx("div", { className: `${baseClass}__empty`, role: "status", children: emptyText }) : /* @__PURE__ */ jsx(
95
+ "div",
96
+ {
97
+ ref: listRef,
98
+ id: mentionListboxId(menuId),
99
+ className: `${baseClass}__list`,
100
+ role: "listbox",
101
+ "aria-label": label,
102
+ tabIndex: 0,
103
+ children: items.map((item) => {
104
+ const active = item.id === activeId;
105
+ return /* @__PURE__ */ jsxs(
106
+ "div",
107
+ {
108
+ id: mentionOptionId(menuId, item.id),
109
+ className: [
110
+ `${baseClass}__option`,
111
+ active ? `${baseClass}__option--active` : "",
112
+ item.disabled ? `${baseClass}__option--disabled` : ""
113
+ ].filter(Boolean).join(" "),
114
+ role: "option",
115
+ "aria-selected": active,
116
+ "aria-disabled": item.disabled || void 0,
117
+ onMouseMove: item.disabled || active ? void 0 : () => onActiveChange?.(item.id),
118
+ onClick: item.disabled ? void 0 : () => onSelect?.(item),
119
+ children: [
120
+ item.icon !== void 0 && /* @__PURE__ */ jsx("span", { className: `${baseClass}__icon`, "aria-hidden": "true", children: typeof item.icon === "string" ? /* @__PURE__ */ jsx("span", { className: "material-symbols-rounded", children: item.icon }) : item.icon }),
121
+ /* @__PURE__ */ jsx("span", { className: `${baseClass}__label`, children: renderLabel(item.label, query) }),
122
+ item.description && /* @__PURE__ */ jsx("span", { className: `${baseClass}__description`, children: item.description })
123
+ ]
124
+ },
125
+ item.id
126
+ );
127
+ })
128
+ }
129
+ )
130
+ }
131
+ );
132
+ }
133
+ );
134
+ MentionMenu.displayName = "MentionMenu";
135
+ export {
136
+ Mention,
137
+ MentionMenu,
138
+ MentionText,
139
+ filterMentionItems,
140
+ getMentionQuery,
141
+ insertMention,
142
+ mentionListboxId,
143
+ mentionOptionId,
144
+ splitMentions
145
+ };
@@ -0,0 +1,81 @@
1
+ import { default as React } from 'react';
2
+ /** One thing a person can reference: a teammate, a file, a skill. */
3
+ export type MentionItem = {
4
+ /** Stable identity, unique within its source. */
5
+ id: string;
6
+ /** The name shown in the menu and written into the text after the trigger character. */
7
+ label: string;
8
+ /** A quieter second phrase on the same row: a role, a path, what a skill does. */
9
+ description?: string;
10
+ /** Leading mark: a Material Symbol name, or any node (an Avatar). Decorative. */
11
+ icon?: string | React.ReactNode;
12
+ /** Extra words the filter matches on besides the label. */
13
+ keywords?: string[];
14
+ /** Listed but not choosable. */
15
+ disabled?: boolean;
16
+ };
17
+ /** Everything one trigger character opens: `@` for entities, `/` for skills. */
18
+ export type MentionSource = {
19
+ /** The single character that opens this source. */
20
+ trigger: string;
21
+ /** The items it offers. */
22
+ items: MentionItem[];
23
+ /** Accessible name for this source's menu ("People", "Skills"). */
24
+ label?: string;
25
+ };
26
+ /** A trigger being typed: where it starts, and what has been typed after it. */
27
+ export type MentionQuery = {
28
+ /** The trigger character that opened it. */
29
+ trigger: string;
30
+ /** The text typed after the trigger, up to the caret. */
31
+ query: string;
32
+ /** Index of the trigger character. */
33
+ start: number;
34
+ /** Index of the caret. */
35
+ end: number;
36
+ };
37
+ /** One run of text from `splitMentions`: plain words, or a recognised mention. */
38
+ export type MentionSegment = {
39
+ type: 'text';
40
+ text: string;
41
+ } | {
42
+ type: 'mention';
43
+ text: string;
44
+ trigger: string;
45
+ item: MentionItem;
46
+ };
47
+ /**
48
+ * Reads the mention being typed at the caret, if there is one: a trigger
49
+ * character that opens a word, followed by no whitespace up to the caret.
50
+ * A trigger in the middle of a word (the slash in a path, the at-sign in an
51
+ * address) opens nothing.
52
+ */
53
+ export declare function getMentionQuery(text: string, caret: number, triggers: string[]): MentionQuery | null;
54
+ /**
55
+ * Narrows and ranks a source's items for a query: labels that start with it
56
+ * first, then labels where a later word starts with it, then labels that
57
+ * contain it, then keyword matches. An empty query returns every item in its
58
+ * given order.
59
+ */
60
+ export declare function filterMentionItems(items: MentionItem[], query: string): MentionItem[];
61
+ /**
62
+ * Splits text into plain runs and the mentions it contains. A mention is a
63
+ * trigger character followed by the exact label of one of that source's
64
+ * items, standing as its own word. Nothing is stored alongside the text: a
65
+ * mention is recognised wherever its name is written, and stops being one
66
+ * the moment the name is edited.
67
+ */
68
+ export declare function splitMentions(text: string, sources: MentionSource[]): MentionSegment[];
69
+ /**
70
+ * Writes a chosen item over the query being typed: the trigger, the label,
71
+ * and one trailing space, so the next word starts clean. Returns the new
72
+ * text and where the caret belongs in it.
73
+ */
74
+ export declare function insertMention(text: string, query: Pick<MentionQuery, 'trigger' | 'start' | 'end'>, item: MentionItem): {
75
+ value: string;
76
+ caret: number;
77
+ };
78
+ /** The DOM id of one option inside a MentionMenu, for `aria-activedescendant`. */
79
+ export declare const mentionOptionId: (menuId: string, itemId: string) => string;
80
+ /** The DOM id of the listbox inside a MentionMenu, for `aria-controls`. */
81
+ export declare const mentionListboxId: (menuId: string) => string;
@@ -0,0 +1,83 @@
1
+ const MAX_QUERY_LENGTH = 48;
2
+ const isSpace = (char) => char === void 0 || /\s/.test(char);
3
+ function getMentionQuery(text, caret, triggers) {
4
+ for (let index = caret - 1; index >= 0 && caret - index <= MAX_QUERY_LENGTH + 1; index -= 1) {
5
+ const char = text[index];
6
+ if (isSpace(char)) return null;
7
+ if (triggers.includes(char) && isSpace(text[index - 1])) {
8
+ return { trigger: char, query: text.slice(index + 1, caret), start: index, end: caret };
9
+ }
10
+ }
11
+ return null;
12
+ }
13
+ function filterMentionItems(items, query) {
14
+ const needle = query.trim().toLowerCase();
15
+ if (needle === "") return items;
16
+ const rank = (item) => {
17
+ const label = item.label.toLowerCase();
18
+ if (label.startsWith(needle)) return 0;
19
+ if (label.split(/[\s\-_:./]+/).some((word) => word.startsWith(needle))) return 1;
20
+ if (label.includes(needle)) return 2;
21
+ if (item.keywords?.some((keyword) => keyword.toLowerCase().includes(needle))) return 3;
22
+ return -1;
23
+ };
24
+ return items.map((item, index) => ({ item, index, rank: rank(item) })).filter((entry) => entry.rank >= 0).sort((a, b) => a.rank - b.rank || a.index - b.index).map((entry) => entry.item);
25
+ }
26
+ const OPENERS = /* @__PURE__ */ new Set(["(", "[", "{", '"', "“", "‘", "'"]);
27
+ const continuesName = (char) => char !== void 0 && /[\p{L}\p{N}_-]/u.test(char);
28
+ function splitMentions(text, sources) {
29
+ const candidates = sources.flatMap(
30
+ (source) => source.items.map((item) => ({
31
+ trigger: source.trigger,
32
+ item,
33
+ token: `${source.trigger}${item.label}`
34
+ }))
35
+ ).sort((a, b) => b.token.length - a.token.length);
36
+ const triggers = new Set(sources.map((source) => source.trigger));
37
+ const segments = [];
38
+ let plainFrom = 0;
39
+ let index = 0;
40
+ while (index < text.length) {
41
+ const before = text[index - 1];
42
+ if (triggers.has(text[index]) && (isSpace(before) || OPENERS.has(before))) {
43
+ const match = candidates.find(
44
+ (candidate) => text.startsWith(candidate.token, index) && !continuesName(text[index + candidate.token.length])
45
+ );
46
+ if (match) {
47
+ if (index > plainFrom) segments.push({ type: "text", text: text.slice(plainFrom, index) });
48
+ segments.push({
49
+ type: "mention",
50
+ text: match.token,
51
+ trigger: match.trigger,
52
+ item: match.item
53
+ });
54
+ index += match.token.length;
55
+ plainFrom = index;
56
+ continue;
57
+ }
58
+ }
59
+ index += 1;
60
+ }
61
+ if (plainFrom < text.length) segments.push({ type: "text", text: text.slice(plainFrom) });
62
+ return segments;
63
+ }
64
+ function insertMention(text, query, item) {
65
+ const before = text.slice(0, query.start);
66
+ const after = text.slice(query.end);
67
+ const token = `${query.trigger}${item.label}`;
68
+ const spacer = after.startsWith(" ") ? "" : " ";
69
+ return {
70
+ value: `${before}${token}${spacer}${after}`,
71
+ caret: before.length + token.length + 1
72
+ };
73
+ }
74
+ const mentionOptionId = (menuId, itemId) => `${menuId}-option-${itemId}`;
75
+ const mentionListboxId = (menuId) => `${menuId}-listbox`;
76
+ export {
77
+ filterMentionItems,
78
+ getMentionQuery,
79
+ insertMention,
80
+ mentionListboxId,
81
+ mentionOptionId,
82
+ splitMentions
83
+ };
@@ -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
+ };