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.
- package/README.md +8 -10
- package/bin/rift-ds.mjs +111 -37
- package/components/AgentRail/AgentRail.css +2 -2
- package/components/AppSidebar/AppSidebar.css +6 -0
- package/components/AppSidebar/AppSidebar.d.ts +12 -2
- package/components/AppSidebar/AppSidebar.js +11 -1
- package/components/Attachment/Attachment.css +14 -0
- package/components/Attachment/AttachmentGroup.d.ts +2 -2
- package/components/Attachment/AttachmentGroup.js +7 -2
- package/components/Composer/Composer.css +88 -4
- package/components/Composer/Composer.d.ts +21 -0
- package/components/Composer/Composer.js +199 -14
- package/components/Mention/Mention.css +195 -0
- package/components/Mention/Mention.d.ts +68 -0
- package/components/Mention/Mention.js +145 -0
- package/components/Mention/mentions.d.ts +81 -0
- package/components/Mention/mentions.js +83 -0
- 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/ShaderField/ShaderField.d.ts +3 -3
- package/components/SourceChip/SourceChip.css +211 -11
- package/components/SourceChip/SourceChip.d.ts +33 -10
- package/components/SourceChip/SourceChip.js +174 -22
- package/components/StatusDot/StatusDot.css +36 -13
- package/components/StatusDot/StatusDot.d.ts +16 -4
- package/components/StatusDot/StatusDot.js +25 -5
- package/components/ThreadPanel/ThreadPanel.css +26 -11
- package/components/ThreadPanel/ThreadPanel.d.ts +18 -4
- package/components/ThreadPanel/ThreadPanel.js +52 -10
- package/components/ThreadTabs/ThreadTabs.css +18 -8
- package/components/ThreadTabs/ThreadTabs.d.ts +8 -2
- package/components/ThreadTabs/ThreadTabs.js +24 -5
- 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 +26 -2
- package/components/registry.json.d.ts +26 -2
- package/components/registry.json.js +1 -1
- package/index.d.ts +3 -0
- package/index.js +21 -0
- package/package.json +5 -1
- 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
|
@@ -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
|
+
};
|