rift-ds 1.6.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.
@@ -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
+ };
@@ -1,8 +1,5 @@
1
1
  import { default as React } from 'react';
2
2
  import { ShaderBlob, ShaderParams } from './useShaderField';
3
- export type { ShaderBlob, ShaderParams, ShaderFieldParams, ShaderFieldState, } from './useShaderField';
4
- export { useShaderField, DEFAULT_SHADER_BLOBS, DEFAULT_SHADER_PARAMS } from './useShaderField';
5
- export { BLOB_COUNT } from './field.glsl';
6
3
  /**
7
4
  * How the renderer has resolved.
8
5
  *
@@ -66,3 +63,6 @@ export interface ShaderFieldProps extends ShaderFieldOwnProps, Omit<React.Compon
66
63
  * the clipping and any mask.
67
64
  */
68
65
  export declare const ShaderField: React.ForwardRefExoticComponent<ShaderFieldProps & React.RefAttributes<HTMLCanvasElement>>;
66
+ export type { ShaderBlob, ShaderParams, ShaderFieldParams, ShaderFieldState, } from './useShaderField';
67
+ export { useShaderField, DEFAULT_SHADER_BLOBS, DEFAULT_SHADER_PARAMS } from './useShaderField';
68
+ export { BLOB_COUNT } from './field.glsl';
@@ -1,7 +1,7 @@
1
1
  /* ============================================
2
2
  STATUS DOT COMPONENT
3
3
  Bare presence/status indicator with optional
4
- label and live pulse
4
+ label, hollow outline and live pulse
5
5
  ============================================ */
6
6
 
7
7
  /* Base */
@@ -16,6 +16,7 @@
16
16
  position: relative;
17
17
  flex-shrink: 0;
18
18
  border-radius: var(--radius-pill);
19
+ background-color: var(--ds-status-dot-color);
19
20
  }
20
21
 
21
22
  .ds-status-dot__label {
@@ -29,7 +30,14 @@
29
30
  }
30
31
 
31
32
  /* Sizes — the dot rides the icon scale at half-diameter, so it can never
32
- drift from the glyphs it sits beside. */
33
+ drift from the glyphs it sits beside. `xs` is the one step off it: the
34
+ list-row mark sits on the gap scale, because no icon token is that small
35
+ and 8px matches a title's cap height. */
36
+
37
+ .ds-status-dot--xs .ds-status-dot__indicator {
38
+ width: var(--gap-200); /* 8px */
39
+ height: var(--gap-200);
40
+ }
33
41
 
34
42
  .ds-status-dot--sm .ds-status-dot__indicator {
35
43
  width: calc(var(--icon-size-500) / 2); /* 10px */
@@ -49,24 +57,39 @@
49
57
  /* Variants — the plain-surface icon steps, tuned to hold on bare
50
58
  containers where the status text tints wash out. */
51
59
 
52
- .ds-status-dot--info .ds-status-dot__indicator {
53
- background-color: var(--color-status-info-icon);
60
+ .ds-status-dot--info {
61
+ --ds-status-dot-color: var(--color-status-info-icon);
62
+ }
63
+
64
+ .ds-status-dot--positive {
65
+ --ds-status-dot-color: var(--color-status-positive-icon);
54
66
  }
55
67
 
56
- .ds-status-dot--positive .ds-status-dot__indicator {
57
- background-color: var(--color-status-positive-icon);
68
+ .ds-status-dot--warning {
69
+ --ds-status-dot-color: var(--color-status-warning-icon);
58
70
  }
59
71
 
60
- .ds-status-dot--warning .ds-status-dot__indicator {
61
- background-color: var(--color-status-warning-icon);
72
+ .ds-status-dot--error {
73
+ --ds-status-dot-color: var(--color-status-error-icon);
62
74
  }
63
75
 
64
- .ds-status-dot--error .ds-status-dot__indicator {
65
- background-color: var(--color-status-error-icon);
76
+ .ds-status-dot--neutral {
77
+ --ds-status-dot-color: var(--color-status-neutral-icon);
78
+ }
79
+
80
+ /* Outline — the hollow dot: the role's colour drawn as an inset ring, so
81
+ the mark keeps its box and its seat while reading as "nothing here yet".
82
+ The two small sizes take the hairline; the larger two the heavier step,
83
+ so the ring keeps its proportion to the diameter. */
84
+
85
+ .ds-status-dot--outline .ds-status-dot__indicator {
86
+ background-color: transparent;
87
+ box-shadow: inset 0 0 0 var(--border-025) var(--ds-status-dot-color);
66
88
  }
67
89
 
68
- .ds-status-dot--neutral .ds-status-dot__indicator {
69
- background-color: var(--color-status-neutral-icon);
90
+ .ds-status-dot--outline.ds-status-dot--md .ds-status-dot__indicator,
91
+ .ds-status-dot--outline.ds-status-dot--lg .ds-status-dot__indicator {
92
+ box-shadow: inset 0 0 0 var(--border-050) var(--ds-status-dot-color);
70
93
  }
71
94
 
72
95
  /* Pulse — a ring of the dot's own colour swelling out and fading, on the
@@ -77,7 +100,7 @@
77
100
  position: absolute;
78
101
  inset: 0;
79
102
  border-radius: var(--radius-pill);
80
- background-color: inherit;
103
+ background-color: var(--ds-status-dot-color);
81
104
  animation: ds-status-dot-pulse var(--motion-duration-loop-shimmer) var(--motion-ease-standard)
82
105
  infinite;
83
106
  }
@@ -3,8 +3,14 @@ import { default as React } from 'react';
3
3
  type StatusDotOwnProps = {
4
4
  /** Status role the dot carries — coloured through the plain-surface `--color-status-*-icon` steps. */
5
5
  variant?: 'info' | 'positive' | 'warning' | 'error' | 'neutral';
6
- /** Dot diameter, derived from the icon scale (half of `--icon-size-500/md/lg`). */
7
- size?: 'sm' | 'md' | 'lg';
6
+ /** Dot diameter, derived from the icon scale (half of `--icon-size-500/600/800`); `xs` is the 8px list-row mark, sized on the gap scale. */
7
+ size?: 'xs' | 'sm' | 'md' | 'lg';
8
+ /**
9
+ * Draws the dot hollow: a ring in the role's colour around an empty
10
+ * centre, for a settled or empty state that still holds its seat beside
11
+ * filled dots.
12
+ */
13
+ outline?: boolean;
8
14
  /**
9
15
  * Radiates a repeating ring from the dot for a live state — recording,
10
16
  * online now, deploy in flight. The ring stills under reduced motion.
@@ -16,6 +22,12 @@ type StatusDotOwnProps = {
16
22
  * a colour alone announces nothing.
17
23
  */
18
24
  label?: string;
25
+ /**
26
+ * Renders the dot as decoration: no `role="status"`, hidden from assistive
27
+ * technology. For a dot repeated down a list, where the host announces the
28
+ * state in the row's own text and a live region per row would be noise.
29
+ */
30
+ decorative?: boolean;
19
31
  /** Additional CSS classes */
20
32
  className?: string;
21
33
  };
@@ -23,8 +35,8 @@ export interface StatusDotProps extends StatusDotOwnProps, Omit<React.ComponentP
23
35
  }
24
36
  /**
25
37
  * StatusDot — the bare presence/status indicator: a small dot in one of the
26
- * five status roles, with an optional visible label and an optional live
27
- * pulse. Where Badge carries a text label on a tinted fill, StatusDot is the
38
+ * five status roles, filled or hollow, with an optional visible label and
39
+ * an optional live pulse. Where Badge carries a text label on a tinted fill, StatusDot is the
28
40
  * mark alone — for table rows, avatars, nav items, and anywhere a full badge
29
41
  * is too loud.
30
42
  *
@@ -2,19 +2,39 @@ import { jsxs, jsx } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import "./StatusDot.css";
4
4
  const StatusDot = React.forwardRef(
5
- ({ variant = "neutral", size = "md", pulse = false, label, className = "", ...rest }, ref) => {
5
+ ({
6
+ variant = "neutral",
7
+ size = "md",
8
+ outline = false,
9
+ pulse = false,
10
+ label,
11
+ decorative = false,
12
+ className = "",
13
+ ...rest
14
+ }, ref) => {
6
15
  const baseClass = "ds-status-dot";
7
16
  const classes = [
8
17
  baseClass,
9
18
  `${baseClass}--${variant}`,
10
19
  `${baseClass}--${size}`,
20
+ outline && `${baseClass}--outline`,
11
21
  pulse && `${baseClass}--pulse`,
12
22
  className
13
23
  ].filter(Boolean).join(" ");
14
- return /* @__PURE__ */ jsxs("span", { ...rest, ref, className: classes, role: "status", children: [
15
- /* @__PURE__ */ jsx("span", { className: `${baseClass}__indicator`, "aria-hidden": "true" }),
16
- label && /* @__PURE__ */ jsx("span", { className: `${baseClass}__label`, children: label })
17
- ] });
24
+ return /* @__PURE__ */ jsxs(
25
+ "span",
26
+ {
27
+ ...rest,
28
+ ref,
29
+ className: classes,
30
+ role: decorative ? void 0 : "status",
31
+ "aria-hidden": decorative ? true : rest["aria-hidden"],
32
+ children: [
33
+ /* @__PURE__ */ jsx("span", { className: `${baseClass}__indicator`, "aria-hidden": "true" }),
34
+ label && /* @__PURE__ */ jsx("span", { className: `${baseClass}__label`, children: label })
35
+ ]
36
+ }
37
+ );
18
38
  }
19
39
  );
20
40
  StatusDot.displayName = "StatusDot";
@@ -531,15 +531,24 @@
531
531
  row-gap: 0;
532
532
  }
533
533
 
534
- /* The unread dot: a small status disc leading the title. Sized on the gap
535
- scale — there is no icon token this small, and 8px matches the title's
536
- cap height. */
537
- .ds-thread-panel__unread {
534
+ /* The status dot: a StatusDot at its 8px list-row size leading the title.
535
+ The panel seats one on every row or on none, so titles share an edge. */
536
+ .ds-thread-panel__status {
538
537
  flex: none;
539
- width: var(--gap-200);
540
- height: var(--gap-200);
541
- border-radius: var(--radius-pill);
542
- background-color: var(--color-status-info-icon);
538
+ }
539
+
540
+ /* The status read aloud: the dot is decorative, so its state rides in the
541
+ row's text for assistive technology only. */
542
+ .ds-thread-panel__status-label {
543
+ position: absolute;
544
+ width: 1px;
545
+ height: 1px;
546
+ padding: 0;
547
+ margin: -1px;
548
+ overflow: hidden;
549
+ clip: rect(0, 0, 0, 0);
550
+ white-space: nowrap;
551
+ border: 0;
543
552
  }
544
553
 
545
554
  .ds-thread-panel__title {
@@ -616,8 +625,8 @@
616
625
  }
617
626
 
618
627
  /* The description line: the caption face on the full second line, fading
619
- at the trailing edge like the title above it. Indented past the unread
620
- dot's column so both lines share a left edge.
628
+ at the trailing edge like the title above it. Indented past the status
629
+ dot's column, when the panel has one, so both lines share a left edge.
621
630
  ds-allow(color): mask alpha keywords (black/transparent), not palette colours */
622
631
  .ds-thread-panel__description {
623
632
  flex-basis: 100%;
@@ -641,7 +650,7 @@
641
650
  );
642
651
  }
643
652
 
644
- .ds-thread-panel__thread--unread .ds-thread-panel__description {
653
+ .ds-thread-panel--dotted .ds-thread-panel__description {
645
654
  /* The dot's width plus the row's column gap. */
646
655
  margin-left: calc(var(--gap-200) + var(--gap-200));
647
656
  }
@@ -9,6 +9,14 @@ export interface ThreadPanelThreadAction {
9
9
  /** Destructive styling (the error text colour) for delete-like actions. */
10
10
  destructive?: boolean;
11
11
  }
12
+ /**
13
+ * What a thread's leading dot reports. Fill says whether the thread wants
14
+ * the reader, colour says what it wants, motion says it is live right now:
15
+ * `idle` is a hollow neutral ring, `working` a pulsing neutral dot, and
16
+ * `unread`, `waiting` and `error` solid dots in the info, warning and error
17
+ * roles.
18
+ */
19
+ export type ThreadPanelThreadStatus = 'idle' | 'working' | 'unread' | 'waiting' | 'error';
12
20
  export interface ThreadPanelThread {
13
21
  /** Stable identifier: `activeThreadId` matches against it and `onThreadSelect` reports it. */
14
22
  id: string;
@@ -24,7 +32,9 @@ export interface ThreadPanelThread {
24
32
  actions?: ThreadPanelThreadAction[];
25
33
  /** Quiet second line under the title in the caption face — a repo, an environment, a one-line summary. Overflows under the same trailing mask as the title. */
26
34
  description?: string;
27
- /** The thread holds unseen activity: a small status dot leads the row. The dot is decorative — put the state in `meta` or `description` when it must be read aloud. */
35
+ /** The thread's state, drawn as the dot leading the row: `idle` (nothing new, nothing running), `working` (an agent is running now), `unread` (finished, not yet seen), `waiting` (blocked on the reader) or `error` (stopped on a failure). Once any thread in the panel carries a status every row draws a dot, the rest reading `idle`, so the titles share one left edge. The dot is decorative; every state but `idle` is also read aloud through `statusLabels`. */
36
+ status?: ThreadPanelThreadStatus;
37
+ /** Legacy boolean for `status: 'unread'`, ignored when `status` is set. @deprecated Use `status: 'unread'` instead. */
28
38
  unread?: boolean;
29
39
  /** Trailing icon before the meta — Material Symbol name (string) or custom element (ReactNode), e.g. `cloud` for a session that lives remotely. */
30
40
  icon?: string | React.ReactNode;
@@ -81,6 +91,8 @@ type ThreadPanelOwnProps = {
81
91
  threadActions?: ThreadPanelThreadAction[];
82
92
  /** Fires with the thread's id and the chosen action's id. */
83
93
  onThreadAction?: (threadId: string, actionId: string) => void;
94
+ /** Spoken text for each thread status, read after the row's title; the dot itself is decorative. Merged over the English defaults (Working, Unread, Needs input, Failed). `idle` is silent unless given a label here. */
95
+ statusLabels?: Partial<Record<ThreadPanelThreadStatus, string>>;
84
96
  /** Accessible name for a row's menu trigger; the thread's title is appended after it. */
85
97
  threadMenuLabel?: string;
86
98
  /** Id of the thread being renamed: its row swaps to an inline text field, prefilled with the title and selected. The host owns the state, like everything else. */
@@ -150,8 +162,8 @@ export interface ThreadPanelProps extends ThreadPanelOwnProps, Omit<React.Compon
150
162
  * control rows, an optional projects section with its own new-project
151
163
  * button, the grouped thread history (the one region that scrolls), and a
152
164
  * footer for the profile row and host furniture like a theme toggle.
153
- * Thread rows scale from a bare title to the full detail anatomy: an
154
- * unread dot, a quiet description line, a trailing glyph, a pin marker,
165
+ * Thread rows scale from a bare title to the full detail anatomy: a
166
+ * leading status dot, a quiet description line, a trailing glyph, a pin marker,
155
167
  * and the meta caption.
156
168
  *
157
169
  * Fully controlled and stateless: the host owns the active thread, the