@gemboss/ui 0.42.0 → 0.43.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.
@@ -1 +1 @@
1
- :root{--gemboss-primary: #1b1c1e;--gemboss-primary-dark: #000000;--gemboss-primary-soft: #f2f3f5;--gemboss-accent: #5661f5;--gemboss-accent-strong: #3a44de;--gemboss-accent-soft: #eef0fe;--gemboss-text-strong: #1b1c1e;--gemboss-text: #1b1c1e;--gemboss-text-muted: #686b71;--gemboss-surface: #ffffff;--gemboss-canvas: #ffffff;--gemboss-preview-canvas: #eceef1;--gemboss-rail-bg: #ffffff;--gemboss-panel-bg: #ffffff;--gemboss-divider: #e5e7ea;--gemboss-invite-h: 0px;--gemboss-divider-soft: #edeff2;--gemboss-hover: #eceef2;--gemboss-active-bg: #f0f1f4;--gemboss-active-fg: #1b1c1e;--gemboss-danger: #d72c0d;--gemboss-danger-strong: #b42318;--gemboss-danger-soft: #fff0e9;--gemboss-danger-border: #f5c9bc;--gemboss-success: #007f5f;--gemboss-success-strong: #0a6a4c;--gemboss-success-soft: #e3f1df;--gemboss-success-border: #b7dfcd;--gemboss-warning: #9a6700;--gemboss-warning-soft: #fff8e0;--gemboss-warning-border: #f5d569;--gemboss-radius-xs: 4px;--gemboss-radius-sm: 6px;--gemboss-radius-md: 8px;--gemboss-radius-lg: 12px;--gemboss-radius-xl: 16px;--gemboss-radius-2xl: 20px;--gemboss-radius-full: 999px;--gemboss-shadow-resting: 0 1px 2px rgba(31, 30, 29, .04), 0 2px 6px rgba(31, 30, 29, .06);--gemboss-shadow-raised: 0 4px 16px rgba(31, 30, 29, .1);--gemboss-shadow-overlay: 0 16px 48px rgba(31, 30, 29, .18);--gemboss-transition-fast: .12s;--gemboss-transition-base: .15s;--gemboss-fs-micro: 11px;--gemboss-fs-caption: 12px;--gemboss-fs-body: 14px;--gemboss-fs-body-lg: 16px;--gemboss-fs-h3: 16px;--gemboss-fs-h2: 20px;--gemboss-fs-h1: 24px;--gemboss-fs-display: 30px;--gemboss-dark-bg: #0a0a0c;--gemboss-dark-rail: #0e0e11;--gemboss-dark-surface: #16161a;--gemboss-dark-surface-2: #1e1e24;--gemboss-dark-divider: #2a2a31;--gemboss-dark-text: #ededf0;--gemboss-dark-muted: #9a9aa4;--gemboss-serif: var(--p-font-family-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);--gemboss-font-sans: var(--p-font-family-sans, "Inter", -apple-system, BlinkMacSystemFont, "San Francisco", "Segoe UI", Roboto, "Helvetica Neue", sans-serif);--gemboss-field-bg: #fdfdfd;--gemboss-field-bg-hover: #fafafa;--gemboss-field-ink: #303030;--gemboss-field-placeholder: #616161;--gemboss-field-line: #8a8a8a;--gemboss-field-line-hover: #616161;--gemboss-field-line-active: #1a1a1a;--gemboss-chip-ink: #303633;--gemboss-chip-line: #cfd6ce;--gemboss-chip-line-hover: #aab4ad;--gemboss-chip-bg-hover: #f6f7f5;--gemboss-on-primary: #ffffff;--gemboss-topbar-bg: rgba(255, 255, 255, .98);--gemboss-nav-bg: var(--gemboss-surface);--gemboss-nav-hover: rgba(20, 19, 18, .045)}:root{--gemboss-primary: #1b1c1e;--gemboss-primary-dark: #000000;--gemboss-primary-soft: #f2f3f5;--gemboss-accent: #5661f5;--gemboss-accent-strong: #3a44de;--gemboss-accent-soft: #eef0fe;--gemboss-active-bg: #f0f1f4;--gemboss-active-fg: #1b1c1e;--gemboss-app-bg: #ffffff}
1
+ :root{--gemboss-primary: #1b1c1e;--gemboss-primary-dark: #000000;--gemboss-primary-soft: #f2f3f5;--gemboss-accent: #5661f5;--gemboss-accent-strong: #3a44de;--gemboss-accent-soft: #eef0fe;--gemboss-accent-wash: #f7f8ff;--gemboss-accent-tint: #dfe2ff;--gemboss-accent-mid: #c9cffd;--gemboss-text-strong: #1b1c1e;--gemboss-text: #1b1c1e;--gemboss-text-muted: #686b71;--gemboss-surface: #ffffff;--gemboss-canvas: #ffffff;--gemboss-preview-canvas: #eceef1;--gemboss-rail-bg: #ffffff;--gemboss-panel-bg: #ffffff;--gemboss-divider: #e5e7ea;--gemboss-invite-h: 0px;--gemboss-divider-soft: #edeff2;--gemboss-hover: #eceef2;--gemboss-active-bg: #f0f1f4;--gemboss-active-fg: #1b1c1e;--gemboss-danger: #d72c0d;--gemboss-danger-strong: #b42318;--gemboss-danger-soft: #fff0e9;--gemboss-danger-border: #f5c9bc;--gemboss-success: #007f5f;--gemboss-success-strong: #0a6a4c;--gemboss-success-soft: #e3f1df;--gemboss-success-border: #b7dfcd;--gemboss-warning: #9a6700;--gemboss-warning-soft: #fff8e0;--gemboss-warning-border: #f5d569;--gemboss-radius-xs: 4px;--gemboss-radius-sm: 6px;--gemboss-radius-md: 8px;--gemboss-radius-lg: 12px;--gemboss-radius-xl: 16px;--gemboss-radius-2xl: 20px;--gemboss-radius-full: 999px;--gemboss-shadow-resting: 0 1px 2px rgba(31, 30, 29, .04), 0 2px 6px rgba(31, 30, 29, .06);--gemboss-shadow-raised: 0 4px 16px rgba(31, 30, 29, .1);--gemboss-shadow-overlay: 0 16px 48px rgba(31, 30, 29, .18);--gemboss-transition-fast: .12s;--gemboss-transition-base: .15s;--gemboss-fs-micro: 11px;--gemboss-fs-caption: 12px;--gemboss-fs-body: 14px;--gemboss-fs-body-lg: 16px;--gemboss-fs-h3: 16px;--gemboss-fs-h2: 20px;--gemboss-fs-h1: 24px;--gemboss-fs-display: 30px;--gemboss-dark-bg: #0a0a0c;--gemboss-dark-rail: #0e0e11;--gemboss-dark-surface: #16161a;--gemboss-dark-surface-2: #1e1e24;--gemboss-dark-divider: #2a2a31;--gemboss-dark-text: #ededf0;--gemboss-dark-muted: #9a9aa4;--gemboss-serif: var(--p-font-family-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);--gemboss-font-sans: var(--p-font-family-sans, "Inter", -apple-system, BlinkMacSystemFont, "San Francisco", "Segoe UI", Roboto, "Helvetica Neue", sans-serif);--gemboss-field-bg: #fdfdfd;--gemboss-field-bg-hover: #fafafa;--gemboss-field-ink: #303030;--gemboss-field-placeholder: #616161;--gemboss-field-line: #8a8a8a;--gemboss-field-line-hover: #616161;--gemboss-field-line-active: #1a1a1a;--gemboss-chip-ink: #303633;--gemboss-chip-line: #cfd6ce;--gemboss-chip-line-hover: #aab4ad;--gemboss-chip-bg-hover: #f6f7f5;--gemboss-on-primary: #ffffff;--gemboss-topbar-bg: rgba(255, 255, 255, .98);--gemboss-nav-bg: var(--gemboss-surface);--gemboss-nav-hover: rgba(20, 19, 18, .045)}:root{--gemboss-primary: #1b1c1e;--gemboss-primary-dark: #000000;--gemboss-primary-soft: #f2f3f5;--gemboss-accent: #5661f5;--gemboss-accent-strong: #3a44de;--gemboss-accent-soft: #eef0fe;--gemboss-active-bg: #f0f1f4;--gemboss-active-fg: #1b1c1e;--gemboss-app-bg: #ffffff}
package/src/icons.tsx CHANGED
@@ -76,6 +76,8 @@ const P = {
76
76
  chevronLeft: <path d="M12.5 4.5 7 10l5.5 5.5" />,
77
77
  chevronsDown: <path d="M6 5.5l4 4 4-4M6 10.5l4 4 4-4" />,
78
78
  panelRight: <><rect x="3" y="3.5" width="14" height="13" rx="2" /><path d="M12.5 3.5v13" /></>,
79
+ // The composer's emoji button (21/10/2026): `reaction`'s face, closed and without the plus (it writes, it does not react).
80
+ emoji: <><circle cx="10" cy="10" r="6.5" /><path d="M7.5 11.8s.9 1.2 2.5 1.2 2.5-1.2 2.5-1.2" /><path d="M7.8 8.2h.01M12.2 8.2h.01" /></>,
79
81
  } satisfies Record<string, React.ReactNode>;
80
82
  // `satisfies`, not a `Record<string, …>` annotation: the annotation widened `keyof` to `string`, so a
81
83
  // typo (`name="setings"`) type-checked and drew an empty box (review #3474).
package/src/index.ts CHANGED
@@ -55,15 +55,21 @@ export { SettingsDialog, type SettingsDialogProps, type SettingsSectionItem } fr
55
55
  export { SearchDialog, type SearchItem, type SearchSection } from "./search-dialog";
56
56
  export { ThemeToggle } from "./theme";
57
57
  export { THEME_KEY, themeScript } from "./theme-script";
58
- export { Reactions, ReactionPicker, type ReactionsProps } from "./reactions";
58
+ export { Reactions, ReactionPicker, type ReactionPickerProps, type ReactionsProps } from "./reactions";
59
59
  export { ActionCard, ActionCardItem, ActionCardSection, ActionChange, type ActionCardItemProps, type ActionCardOutcome, type ActionCardProps } from "./action-card";
60
+ // 0.43.0 (the Market redesign, 21/10/2026): the assistant asks one question and a person only confirms, and the pieces around it.
61
+ export { QuestionCard, MAX_QUESTION_CHOICES, type QuestionCardProps, type QuestionChoice, type QuestionChoices } from "./question-card";
62
+ export { BriefingCard, type BriefingCardProps } from "./briefing-card";
63
+ export { RangeBar, rangeBarGeometry, type RangeBarGeometry, type RangeBarProps } from "./range-bar";
64
+ export { ProgressRing, progressRingGeometry, RING_BOX, type ProgressRingGeometry, type ProgressRingProps } from "./progress-ring";
65
+ export { SpotArt, SPOT_ART_NAMES, type SpotArtName, type SpotArtProps } from "./spot-art";
60
66
  export { FREQUENT_MAX, frequentReactions, isReactionEmoji, QUICK_REACTIONS, REACTION_EMOJI, REACTION_GROUPS, reactionLabel, reactionWords, searchReactions, SUGGESTED_REACTIONS, toggleReaction, type Reaction } from "./reactions-data";
61
67
  // The team chat layer (20/10/2026, README "The team chat layer"): GemCare's channel chat for every surface, with
62
68
  // team-chat.css its own entry. The row rules (turns, day separators, the "New messages" line) are plain data.
63
69
  export {
64
70
  ChannelItem, ChannelRail, ChannelSection, ChatHeader, Composer, DaySeparator, JumpToLatest, Message, MessageList, NewMessagesLine,
65
71
  PeoplePanel, PeopleSection, PersonRow, ReadReceipt, TeamChat, ThreadPanel, TEAM_CHAT_DRAWER_MAX, TEAM_CHAT_LABELS, TEAM_CHAT_PHONE_MAX,
66
- type ChannelItemProps, type ChannelKind, type ChannelRailProps, type ChatHeaderProps, type ComposerProps, type MessageListProps, type MessageProps,
72
+ type ChannelItemProps, type ChannelKind, type ChannelRailProps, type ChatHeaderProps, type ComposerProps, type DmAvatar, type MessageListProps, type MessageProps,
67
73
  type PeoplePanelProps, type PersonRowProps, type Presence, type TeamChatLabels, type TeamChatPanel, type TeamChatProps, type TeamChatView, type ThreadPanelProps,
68
74
  } from "./team-chat";
69
75
  export { chatDayISO, chatDayKey, chatDayWord, teamChatRows, type ChatRow, type ChatRowMessage, type ChatRowOptions } from "./team-chat-data";
@@ -0,0 +1 @@
1
+ .ui-ring{display:block;flex:none;max-width:100%;height:auto}.ui-ring__track,.ui-ring__arc{fill:none;stroke-width:10}.ui-ring__track{stroke:var(--gemboss-accent-tint, #dfe2ff)}.ui-ring__arc{stroke:var(--gemboss-accent, #5661f5);stroke-linecap:round}.ui-ring__label{fill:var(--ui-text-strong, #1b1c1e);font-size:26px;font-weight:700;font-variant-numeric:tabular-nums}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Progress ring — @gemboss/ui 0.43.0. "3 of 5" as a ring with the count in the middle (the Market redesign 21/10/2026: Your store
3
+ * asks five things one at a time and says how far it is). The kit's `ui-progress` is a bar; a bar says "how much", a ring beside a
4
+ * heading says "how far along" without taking a row.
5
+ *
6
+ * Server-renderable (no state, no effect). The colours are tokens (`--gemboss-accent`, `--gemboss-accent-tint`), so the dark
7
+ * palette follows. A page that writes the markup itself computes the arc with `progressRingGeometry` (pure, `@gemboss/ui`
8
+ * index) or by hand, and links `progress-ring.css`.
9
+ */
10
+ import "./progress-ring.css";
11
+ import React, { type ReactNode } from "react";
12
+
13
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
14
+ const r1 = (n: number) => Math.round(n * 10) / 10;
15
+
16
+ /** The viewBox is 104 wide: a 10px stroke on a 42px radius leaves 5px of margin. */
17
+ export const RING_BOX = 104;
18
+ const RING_R = 42;
19
+
20
+ export interface ProgressRingGeometry {
21
+ /** The share done, 0 to 1. */
22
+ fraction: number;
23
+ /** The length of the whole circle, the second number of `stroke-dasharray`. */
24
+ circumference: number;
25
+ /** The length of the arc that is done, the first number of `stroke-dasharray`. */
26
+ dash: number;
27
+ }
28
+
29
+ /**
30
+ * The arc of a ring. `value` is clamped into 0..max, and a `max` that is not a positive number is an empty ring, never NaN
31
+ * in the markup: "0 of 0" is a ring with nothing in it.
32
+ */
33
+ export function progressRingGeometry(value: number, max: number): ProgressRingGeometry {
34
+ const circumference = r1(2 * Math.PI * RING_R);
35
+ const fraction = Number.isFinite(value) && Number.isFinite(max) && max > 0 ? Math.min(Math.max(value, 0), max) / max : 0;
36
+ return { fraction, circumference, dash: r1(fraction * circumference) };
37
+ }
38
+
39
+ export interface ProgressRingProps {
40
+ value: number;
41
+ max: number;
42
+ /** The size in px (default 104). The ring scales; the text inside scales with it. */
43
+ size?: number;
44
+ /** Inside the ring (default "value/max"). */
45
+ label?: ReactNode;
46
+ /** What a screen reader hears (default "value of max"). */
47
+ ariaLabel?: string;
48
+ className?: string;
49
+ }
50
+
51
+ export function ProgressRing({ value, max, size = RING_BOX, label, ariaLabel, className }: ProgressRingProps) {
52
+ const g = progressRingGeometry(value, max);
53
+ // The words say what the arc says: whole numbers, inside 0..max.
54
+ const total = Number.isFinite(max) ? Math.max(Math.round(max), 0) : 0;
55
+ const shown = Math.min(Math.max(Math.round(Number.isFinite(value) ? value : 0), 0), total);
56
+ const mid = RING_BOX / 2;
57
+ return (
58
+ <svg className={cx("ui-ring", className)} viewBox={`0 0 ${RING_BOX} ${RING_BOX}`} width={size} height={size} role="img"
59
+ aria-label={ariaLabel ?? `${shown} of ${total}`}>
60
+ <circle className="ui-ring__track" cx={mid} cy={mid} r={RING_R} />
61
+ <circle className="ui-ring__arc" cx={mid} cy={mid} r={RING_R} strokeDasharray={`${g.dash} ${g.circumference}`} transform={`rotate(-90 ${mid} ${mid})`} />
62
+ <text className="ui-ring__label" x={mid} y={mid + 8} textAnchor="middle">{label ?? `${shown}/${total}`}</text>
63
+ </svg>
64
+ );
65
+ }
@@ -0,0 +1 @@
1
+ .ui-qcard{display:grid;gap:24px;min-width:0;padding:28px;border:1px solid var(--ui-border);border-radius:var(--gemboss-radius-xl, 16px);background:var(--ui-surface);box-shadow:var(--gemboss-shadow-resting, 0 1px 2px rgba(31, 30, 29, .04), 0 2px 6px rgba(31, 30, 29, .06))}.ui-qcard__head{display:flex;align-items:center;gap:24px;min-width:0}.ui-qcard__art{flex:none}.ui-qcard__ask{display:grid;gap:6px;min-width:0}.ui-qcard__eyebrow{margin:0;font-size:var(--gemboss-fs-caption, 12px);line-height:16px;color:var(--ui-text-muted)}.ui-qcard__question{margin:0;font-size:var(--gemboss-fs-h2, 20px);line-height:28px;font-weight:650;letter-spacing:-.01em;color:var(--ui-text-strong);overflow-wrap:anywhere}.ui-qcard__evidence{display:flex;flex-wrap:wrap;gap:8px;margin-top:2px}.ui-qcard__choices{display:grid;gap:10px;min-width:0}.ui-qcard__choice{position:relative;display:flex;align-items:center;gap:14px;min-width:0;min-height:44px;box-sizing:border-box;padding:14px 16px;border:1px solid var(--ui-border);border-radius:var(--gemboss-radius-lg, 12px);background:var(--ui-surface);cursor:pointer;transition:border-color var(--gemboss-transition-fast, .12s),background-color var(--gemboss-transition-fast, .12s)}.ui-qcard__choice:hover{border-color:var(--ui-text-muted)}.ui-qcard__input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.ui-qcard__choice:has(>input:checked){border-color:var(--gemboss-accent, #5661f5);background:var(--gemboss-accent-wash, #f7f8ff);box-shadow:0 0 0 3px var(--gemboss-accent-soft, #eef0fe)}.ui-qcard__choice:has(>input:focus-visible){outline:2px solid var(--ui-focus);outline-offset:2px}.ui-qcard__choice:has(>input:disabled){cursor:default;opacity:.6}.ui-qcard__dot{flex:none;display:inline-grid;place-content:center;width:18px;height:18px;box-sizing:border-box;border:1.5px solid var(--ui-text-muted);border-radius:50%}.ui-qcard__choice:has(>input:checked) .ui-qcard__dot{border-color:var(--gemboss-accent, #5661f5);background:var(--gemboss-accent, #5661f5)}.ui-qcard__choice:has(>input:checked) .ui-qcard__dot:after{content:"";width:6px;height:6px;border-radius:50%;background:var(--gemboss-surface, #fff)}.ui-qcard__text{display:grid;flex:1;gap:2px;min-width:0}.ui-qcard__label{font-weight:600;color:var(--ui-text-strong);overflow-wrap:anywhere}.ui-qcard__note{font-size:var(--gemboss-fs-caption, 12px);line-height:16px;color:var(--ui-text-muted)}.ui-qcard__pick{flex:none;background:var(--gemboss-accent-soft, #eef0fe);color:var(--gemboss-accent-strong, #3a44de)}.ui-qcard__foot{display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px}.ui-qcard__confirm{min-height:36px;padding-inline:22px}.ui-qcard__fine{flex-basis:100%;margin:0;font-size:var(--gemboss-fs-caption, 12px);line-height:16px;color:var(--ui-text-muted)}@media (pointer: coarse),(max-width: 47.9975em){.ui-qcard{padding:20px}.ui-qcard__choice{min-height:48px}.ui-qcard__confirm{min-height:44px}}
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Question card — @gemboss/ui 0.43.0. The one look for "the assistant asks, a person only confirms" (Chris 21/10/2026, the Market
3
+ * redesign: "trợ lý trang cũng phải đặt câu hỏi rõ ràng và có thể là cần fill sẵn câu trả lời chứ ko bắt user phải nghĩ gì hết chỉ
4
+ * confirm thôi", "thậm chí đưa cho họ 1 - 3 lựa chọn tối đa"). README "Question card".
5
+ *
6
+ * The contract it draws, which every surface keeps:
7
+ * - ONE question at a time, in the person's words;
8
+ * - the answer is already chosen: the first choice is the assistant's best guess (`pick`), with where it came from in `note`
9
+ * ("From your site"), so the person confirms instead of thinking;
10
+ * - ONE TO THREE choices, never more: the type refuses a fourth (`QuestionChoices`), and a list that gets past the type
11
+ * (a cast, plain JS) is cut to the first three, because a fourth is a form;
12
+ * - one primary button confirms the chosen one; typing your own answer and skipping are quiet buttons, offered only when the
13
+ * surface handles them;
14
+ * - nothing is saved by choosing: `onChange` moves the selection, `onConfirm` is the only thing that commits (the surface shows
15
+ * Undo after, as `ActionCard` does).
16
+ *
17
+ * Presentational and controlled: no state of its own, so it renders on the server. The choices are real radio inputs (a keyboard
18
+ * and a screen reader get a radio group, the arrow keys move the selection); the dot and the highlight are drawn by the card.
19
+ * Its stylesheet travels with it (`question-card.css`, own entry; `@gemboss/ui/question-card.css` for a plain-HTML surface that
20
+ * writes the same classes).
21
+ */
22
+ import "./question-card.css";
23
+ import React, { type ReactNode } from "react";
24
+
25
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
26
+
27
+ /** The most choices a question offers. Said once, so a surface and its test read the same number. */
28
+ export const MAX_QUESTION_CHOICES = 3;
29
+
30
+ export interface QuestionChoice {
31
+ /** What `value`, `onChange` and `onConfirm` carry. Unique in the question. */
32
+ id: string;
33
+ label: ReactNode;
34
+ /** Where the choice came from, one short line: "From your site", "From ketone.com's ads". */
35
+ note?: ReactNode;
36
+ /** The assistant's best guess: gets the pick tag. At most one choice should carry it, and it goes first. */
37
+ pick?: boolean;
38
+ }
39
+
40
+ /** One, two or three choices. A fourth does not type-check. */
41
+ export type QuestionChoices =
42
+ | readonly [QuestionChoice]
43
+ | readonly [QuestionChoice, QuestionChoice]
44
+ | readonly [QuestionChoice, QuestionChoice, QuestionChoice];
45
+
46
+ export interface QuestionCardProps {
47
+ /** The radio group's name and the base of the ids this card writes. Unique on the page. */
48
+ id: string;
49
+ question: ReactNode;
50
+ /** Above the question: "Question 2 of 4". */
51
+ eyebrow?: ReactNode;
52
+ /** Small chips under the question that say what the guess rests on: "$59.95 to $110.70". */
53
+ evidence?: ReactNode;
54
+ /** A picture beside the question (`SpotArt`). */
55
+ art?: ReactNode;
56
+ choices: QuestionChoices;
57
+ /** The `id` of the chosen choice. Start with the `pick`. */
58
+ value: string;
59
+ onChange: (id: string) => void;
60
+ /** "Yes, that one". Names what happens. */
61
+ confirmLabel: ReactNode;
62
+ onConfirm: (id: string) => void;
63
+ /** The tag on the pick (default "Gem's pick"). */
64
+ pickLabel?: ReactNode;
65
+ /** Typing an answer of their own: shown only when handled. */
66
+ onOther?: () => void;
67
+ otherLabel?: ReactNode;
68
+ onSkip?: () => void;
69
+ skipLabel?: ReactNode;
70
+ /** Under the buttons: "Nothing is saved until you confirm." */
71
+ fine?: ReactNode;
72
+ /** While the confirm is being saved: the card stops taking input. */
73
+ busy?: boolean;
74
+ className?: string;
75
+ }
76
+
77
+ export function QuestionCard({
78
+ id, question, eyebrow, evidence, art, choices, value, onChange, confirmLabel, onConfirm, pickLabel = "Gem's pick",
79
+ onOther, otherLabel = "Type my own", onSkip, skipLabel = "Skip", fine, busy, className,
80
+ }: QuestionCardProps) {
81
+ const list: readonly QuestionChoice[] = choices.slice(0, MAX_QUESTION_CHOICES);
82
+ const qid = `${id}-q`;
83
+ return (
84
+ <section className={cx("ui-qcard", className)} aria-labelledby={qid} aria-busy={busy || undefined}>
85
+ <header className="ui-qcard__head">
86
+ {art ? <div className="ui-qcard__art" aria-hidden>{art}</div> : null}
87
+ <div className="ui-qcard__ask">
88
+ {eyebrow ? <p className="ui-qcard__eyebrow">{eyebrow}</p> : null}
89
+ <h2 className="ui-qcard__question" id={qid}>{question}</h2>
90
+ {evidence ? <div className="ui-qcard__evidence">{evidence}</div> : null}
91
+ </div>
92
+ </header>
93
+ <div className="ui-qcard__choices" role="radiogroup" aria-labelledby={qid}>
94
+ {list.map((c) => (
95
+ <label className="ui-qcard__choice" key={c.id}>
96
+ <input className="ui-qcard__input" type="radio" name={id} value={c.id} checked={value === c.id} disabled={busy}
97
+ onChange={() => onChange(c.id)} />
98
+ <span className="ui-qcard__dot" aria-hidden />
99
+ <span className="ui-qcard__text">
100
+ <span className="ui-qcard__label">{c.label}</span>
101
+ {c.note ? <span className="ui-qcard__note">{c.note}</span> : null}
102
+ </span>
103
+ {c.pick ? <span className="ui-tag ui-tag--accent ui-qcard__pick">{pickLabel}</span> : null}
104
+ </label>
105
+ ))}
106
+ </div>
107
+ <div className="ui-qcard__foot">
108
+ <button type="button" className="ui-btn ui-btn--primary ui-qcard__confirm" disabled={busy || !list.some((c) => c.id === value)}
109
+ onClick={() => onConfirm(value)}>{confirmLabel}</button>
110
+ {onOther ? <button type="button" className="ui-btn ui-btn--ghost" disabled={busy} onClick={onOther}>{otherLabel}</button> : null}
111
+ {onSkip ? <button type="button" className="ui-btn ui-btn--ghost" disabled={busy} onClick={onSkip}>{skipLabel}</button> : null}
112
+ {fine ? <p className="ui-qcard__fine">{fine}</p> : null}
113
+ </div>
114
+ </section>
115
+ );
116
+ }
@@ -0,0 +1 @@
1
+ .ui-rangebar{position:relative;min-width:0}.ui-rangebar--sm{height:44px}.ui-rangebar--lg{height:132px}.ui-rangebar__track,.ui-rangebar__grid,.ui-rangebar__band,.ui-rangebar__mid,.ui-rangebar__you{position:absolute;display:block}.ui-rangebar__track{left:0;right:0;height:6px;border-radius:3px;background:var(--gemboss-divider-soft, #edeff2)}.ui-rangebar--sm .ui-rangebar__track{top:20px;height:4px}.ui-rangebar--lg .ui-rangebar__track{top:56px}.ui-rangebar__grid{width:1px;background:var(--gemboss-divider-soft, #edeff2)}.ui-rangebar--sm .ui-rangebar__grid{display:none}.ui-rangebar--lg .ui-rangebar__grid{top:36px;height:46px}.ui-rangebar__band{background:var(--gemboss-accent-tint, #dfe2ff)}.ui-rangebar--sm .ui-rangebar__band{top:14px;height:16px;border-radius:6px}.ui-rangebar--lg .ui-rangebar__band{top:44px;height:30px;border-radius:var(--gemboss-radius-lg, 12px)}.ui-rangebar__mid{width:2px;background:var(--gemboss-accent, #5661f5)}.ui-rangebar--sm .ui-rangebar__mid{top:10px;height:24px}.ui-rangebar--lg .ui-rangebar__mid{top:36px;height:46px}.ui-rangebar__you{box-sizing:border-box;border-radius:50%;background:var(--gemboss-text-strong, #1b1c1e);border:3px solid var(--gemboss-surface, #fff);box-shadow:0 0 0 1px var(--gemboss-text-strong, #1b1c1e);transform:translate(-50%)}.ui-rangebar--sm .ui-rangebar__you{top:13px;width:18px;height:18px}.ui-rangebar--lg .ui-rangebar__you{top:46px;width:26px;height:26px;border-width:4px}.ui-rangebar__tag,.ui-rangebar__caption,.ui-rangebar__tick{position:absolute;font-size:var(--gemboss-fs-caption, 12px);line-height:16px;white-space:nowrap;font-variant-numeric:tabular-nums}.ui-rangebar__tag{top:10px;transform:translate(-50%);font-weight:650;color:var(--ui-text-strong, #1b1c1e)}.ui-rangebar__tag--mid{color:var(--gemboss-accent-strong, #3a44de)}.ui-rangebar__tag--you{color:var(--ui-text-strong, #1b1c1e)}.ui-rangebar__caption{top:92px;text-align:center;color:var(--ui-text-muted, #686b71)}.ui-rangebar__tick{top:116px;transform:translate(-50%);color:var(--ui-text-muted, #686b71)}.ui-rangebar__tick--start{transform:none}.ui-rangebar__tick--end{transform:translate(-100%)}
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Range bar — @gemboss/ui 0.43.0. One value against a market's spread, on one axis: the band where half of the market sits, its
3
+ * middle, and where you are (the Market redesign 21/10/2026: "where your prices sit"). The kit's `ShareBar` divides one whole,
4
+ * `Sparkline` draws a trend, `ui-progress` one value; none of them says "here is the spread, and here is you inside or outside it".
5
+ *
6
+ * Two sizes. `sm` is the shape alone, for a metric tile. `lg` adds the labels: the middle above its tick, "You" above the dot,
7
+ * what the band is under it, and the axis numbers under that. The numbers are the surface's (`format`, `labels`): the bar does no
8
+ * money maths and names no currency.
9
+ *
10
+ * Server-renderable (no state, no effect). The colours are tokens, so the dark palette follows. Positions are inline percentages:
11
+ * a plain-HTML page under a CSP that forbids inline style (gemcare: `style-src 'self'`) computes them with `rangeBarGeometry`
12
+ * (pure, `@gemboss/ui` index) and sets them from its own stylesheet or script.
13
+ */
14
+ import "./range-bar.css";
15
+ import React, { type ReactNode } from "react";
16
+
17
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
18
+ const r2 = (n: number) => Math.round(n * 100) / 100;
19
+ const isNum = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n);
20
+
21
+ export interface RangeBarGeometry {
22
+ /** The band, in percent of the axis; null when it was not given, or when it has no width left inside the axis. */
23
+ band: { left: number; width: number } | null;
24
+ /** The middle, in percent of the axis. */
25
+ mid: number | null;
26
+ /** The value, in percent of the axis. */
27
+ you: number | null;
28
+ /** Each tick: its value and its place, in percent. */
29
+ ticks: Array<{ value: number; at: number }>;
30
+ }
31
+
32
+ /**
33
+ * Where each thing sits on the axis, as percentages. A value outside the axis is pinned to its edge (a price far above the range
34
+ * still shows, at the end, instead of leaving the bar), a band given high-to-low is turned round, and anything that is not a finite
35
+ * number is left out rather than drawn at NaN%. An axis with no width draws nothing.
36
+ */
37
+ export function rangeBarGeometry(opts: {
38
+ axis: readonly [number, number]; band?: readonly [number, number] | null; mid?: number | null; you?: number | null; ticks?: readonly number[];
39
+ }): RangeBarGeometry {
40
+ const [lo, hi] = opts.axis;
41
+ const empty: RangeBarGeometry = { band: null, mid: null, you: null, ticks: [] };
42
+ if (!isNum(lo) || !isNum(hi) || hi <= lo) return empty;
43
+ const span = hi - lo;
44
+ const at = (v: number) => r2((Math.min(Math.max(v, lo), hi) - lo) / span * 100);
45
+ let band: RangeBarGeometry["band"] = null;
46
+ if (opts.band && isNum(opts.band[0]) && isNum(opts.band[1])) {
47
+ const a = at(Math.min(opts.band[0], opts.band[1]));
48
+ const b = at(Math.max(opts.band[0], opts.band[1]));
49
+ band = b > a ? { left: a, width: r2(b - a) } : null;
50
+ }
51
+ return {
52
+ band,
53
+ mid: isNum(opts.mid) ? at(opts.mid) : null,
54
+ you: isNum(opts.you) ? at(opts.you) : null,
55
+ ticks: (opts.ticks ?? []).filter(isNum).filter((v) => v >= lo && v <= hi).map((value) => ({ value, at: at(value) })),
56
+ };
57
+ }
58
+
59
+ export interface RangeBarProps {
60
+ /** The two ends of the axis, low then high. */
61
+ axis: readonly [number, number];
62
+ /** Where half of the market sits: low then high. */
63
+ band?: readonly [number, number] | null;
64
+ /** The market's middle. */
65
+ mid?: number | null;
66
+ /** The value being placed. */
67
+ you?: number | null;
68
+ /** Axis numbers to print under the bar (`lg` only), e.g. [0, 20, 40, 60, 80]. */
69
+ ticks?: readonly number[];
70
+ /** A number as the surface writes it: "$26.99". Used for the tick labels. */
71
+ format?: (n: number) => string;
72
+ /** The words (`lg` only): above the middle's tick, above the dot, under the band. */
73
+ labels?: { mid?: ReactNode; you?: ReactNode; band?: ReactNode };
74
+ size?: "sm" | "lg";
75
+ /** What a screen reader hears: say the numbers, the bar has no text of its own to read. */
76
+ ariaLabel: string;
77
+ className?: string;
78
+ }
79
+
80
+ const pos = (n: number) => ({ left: `${n}%` });
81
+
82
+ export function RangeBar({ axis, band, mid, you, ticks, format, labels, size = "lg", ariaLabel, className }: RangeBarProps) {
83
+ const g = rangeBarGeometry({ axis, band, mid, you, ticks });
84
+ const lg = size === "lg";
85
+ return (
86
+ <div className={cx("ui-rangebar", lg ? "ui-rangebar--lg" : "ui-rangebar--sm", className)} role="img" aria-label={ariaLabel}>
87
+ {g.ticks.map((t) => <span className="ui-rangebar__grid" style={pos(t.at)} key={t.value} />)}
88
+ <span className="ui-rangebar__track" />
89
+ {g.band ? <span className="ui-rangebar__band" style={{ left: `${g.band.left}%`, width: `${g.band.width}%` }} /> : null}
90
+ {g.mid !== null ? <span className="ui-rangebar__mid" style={pos(g.mid)} /> : null}
91
+ {g.you !== null ? <span className="ui-rangebar__you" style={pos(g.you)} /> : null}
92
+ {lg && g.mid !== null && labels?.mid ? <span className="ui-rangebar__tag ui-rangebar__tag--mid" style={pos(g.mid)}>{labels.mid}</span> : null}
93
+ {lg && g.you !== null && labels?.you ? <span className="ui-rangebar__tag ui-rangebar__tag--you" style={pos(g.you)}>{labels.you}</span> : null}
94
+ {lg && g.band && labels?.band ? <span className="ui-rangebar__caption" style={{ left: `${g.band.left}%`, width: `${g.band.width}%` }}>{labels.band}</span> : null}
95
+ {lg ? g.ticks.map((t) => (
96
+ <span className={cx("ui-rangebar__tick", t.at <= 0 && "ui-rangebar__tick--start", t.at >= 100 && "ui-rangebar__tick--end")} style={pos(t.at)} key={`t${t.value}`}>
97
+ {format ? format(t.value) : String(t.value)}
98
+ </span>
99
+ )) : null}
100
+ </div>
101
+ );
102
+ }
package/src/reactions.tsx CHANGED
@@ -13,6 +13,8 @@
13
13
  * bottom edge; a row in flow is scrolled to by the browser). Frequently used first (this browser's, filled up to nine
14
14
  * with SUGGESTED_REACTIONS), then the groups; search by words; arrows move in the grid, Enter in the search picks the first, Escape closes
15
15
  * and gives focus back to the button.
16
+ * - the same picker WRITES an emoji in the team chat composer (team-chat.tsx `Composer`, 21/10/2026): no `items`, so plain
17
+ * buttons, nothing pressed; its picks go into the same "Frequently used".
16
18
  *
17
19
  * Each target is 44px (a control, CLAUDE.md §9.1) while the visible pill is 26px: transparent padding carries the rest,
18
20
  * so a message with reactions does not grow a whole line.
@@ -31,7 +33,9 @@ function readRecent(): string[] {
31
33
  return Array.isArray(v) ? v.filter((e): e is string => typeof e === "string" && reactionWords(e) !== "").slice(0, RECENT_MAX) : [];
32
34
  } catch { return []; }
33
35
  }
34
- function rememberRecent(emoji: string): void {
36
+ /** Put an emoji first in this browser's "Frequently used" (the picker's row). The composer's emoji button records its picks
37
+ * here too, so a person's emoji are one list whether they reacted with them or wrote them. */
38
+ export function rememberRecent(emoji: string): void {
35
39
  try {
36
40
  const next = [emoji, ...readRecent().filter((e) => e !== emoji)].slice(0, RECENT_MAX);
37
41
  globalThis.localStorage?.setItem(RECENT_KEY, JSON.stringify(next));
@@ -89,17 +93,39 @@ export function Reactions({ items, onToggle, target = "this", canReact = true, o
89
93
  );
90
94
  }
91
95
 
92
- export function ReactionPicker({ items, onPick, onClose, id }: { items: readonly Reaction[]; onPick: (emoji: string) => void; onClose: () => void; id?: string }) {
96
+ export type ReactionPickerProps = {
97
+ /** The reactions already put on the thing: the viewer's own are drawn pressed, and a pick of one takes it back. Absent: a
98
+ * picker that WRITES an emoji (the team chat composer's): plain buttons named by the emoji and its words, none pressed. */
99
+ items?: readonly Reaction[];
100
+ onPick: (emoji: string) => void;
101
+ /** Escape. */
102
+ onClose: () => void;
103
+ id?: string;
104
+ /** The group's accessible name. Default "Pick a reaction". */
105
+ label?: string;
106
+ className?: string;
107
+ /** Where focus goes when it opens: the search (default), or the first emoji (under a finger, where a focused search
108
+ * brings the keyboard up over the picker). */
109
+ initialFocus?: "search" | "emoji";
110
+ };
111
+
112
+ export function ReactionPicker({ items, onPick, onClose, id, label = "Pick a reaction", className, initialFocus = "search" }: ReactionPickerProps) {
93
113
  const [q, setQ] = useState("");
94
114
  const [recent, setRecent] = useState<readonly string[]>(() => frequentReactions([]));
95
115
  const search = useRef<HTMLInputElement>(null);
96
116
  const body = useRef<HTMLDivElement>(null);
97
117
  // Storage is read after mount: the server render and the first client render must draw the same picker.
98
- useEffect(() => { const r = readRecent(); if (r.length) setRecent(frequentReactions(r)); search.current?.focus(); }, []);
99
- const mine = (e: string) => items.some((r) => r.emoji === e && r.mine);
118
+ useEffect(() => {
119
+ const r = readRecent();
120
+ if (r.length) setRecent(frequentReactions(r));
121
+ if (initialFocus === "emoji") body.current?.querySelector<HTMLButtonElement>(".ui-reaction-picker__btn")?.focus();
122
+ else search.current?.focus();
123
+ }, []);
124
+ const mine = (e: string) => Boolean(items?.some((r) => r.emoji === e && r.mine));
100
125
  const btn = (e: string) => (
101
126
  <button key={e} type="button" className="ui-reaction-picker__btn" title={reactionWords(e).split(" ")[0] || e}
102
- aria-label={`${mine(e) ? "Remove" : "Add"} ${e} ${reactionWords(e)}`.trim()} aria-pressed={mine(e)} onClick={() => onPick(e)}>{e}</button>
127
+ aria-label={(items ? `${mine(e) ? "Remove" : "Add"} ${e} ${reactionWords(e)}` : `${e} ${reactionWords(e)}`).trim()}
128
+ aria-pressed={items ? mine(e) : undefined} onClick={() => onPick(e)}>{e}</button>
103
129
  );
104
130
  const grid = (list: readonly string[]) => <div className="ui-reaction-picker__grid">{list.map(btn)}</div>;
105
131
  const found = q.trim() ? searchReactions(q) : null;
@@ -122,7 +148,7 @@ export function ReactionPicker({ items, onPick, onClose, id }: { items: readonly
122
148
  all[Math.min(all.length - 1, at + step)]?.focus();
123
149
  };
124
150
  return (
125
- <div id={id} className="ui-reaction-picker" role="group" aria-label="Pick a reaction" onKeyDown={onKeyDown}>
151
+ <div id={id} className={["ui-reaction-picker", className].filter(Boolean).join(" ")} role="group" aria-label={label} onKeyDown={onKeyDown}>
126
152
  <input ref={search} type="search" className="gemboss-field ui-reaction-picker__search" placeholder="Search emoji" aria-label="Search emoji"
127
153
  autoComplete="off" value={q} onChange={(e) => setQ(e.target.value)} />
128
154
  <div ref={body} className="ui-reaction-picker__body">
@@ -0,0 +1 @@
1
+ .ui-art{--ui-art-solid: var(--gemboss-accent, #5661f5);--ui-art-tint: var(--gemboss-accent-tint, #dfe2ff);--ui-art-mid: var(--gemboss-accent-mid, #c9cffd);--ui-art-soft: var(--gemboss-accent-soft, #eef0fe);--ui-art-wash: var(--gemboss-accent-wash, #f7f8ff);--ui-art-paper: var(--gemboss-surface, #ffffff);--ui-art-ink: var(--gemboss-text-strong, #1b1c1e);--ui-art-muted: var(--gemboss-divider, #e5e7ea);display:block;flex:none;max-width:100%;height:auto}.ui-art__line{fill:none;stroke:var(--ui-art-solid);stroke-width:2;stroke-linejoin:round;stroke-linecap:round}.ui-art__thin{stroke-width:1.5}.ui-art__mid-line{stroke:var(--ui-art-mid)}.ui-art__paper-line{stroke:var(--ui-art-paper);stroke-width:4}.ui-art__dashed{stroke-dasharray:4 5}.ui-art__dots{stroke-dasharray:1 6}.ui-art__ring{fill:none;stroke:var(--ui-art-tint);stroke-width:1.5}.ui-art__dash{fill:none;stroke:var(--ui-art-mid);stroke-width:1.5;stroke-dasharray:3 6}.ui-art__plus{fill:none;stroke:var(--ui-art-paper);stroke-width:2.4;stroke-linecap:round}.ui-art__solid{fill:var(--ui-art-solid)}.ui-art__tint{fill:var(--ui-art-tint)}.ui-art__mid{fill:var(--ui-art-mid)}.ui-art__soft{fill:var(--ui-art-soft)}.ui-art__wash{fill:var(--ui-art-wash)}.ui-art__paper{fill:var(--ui-art-paper)}.ui-art__ink{fill:var(--ui-art-ink)}.ui-art__muted{fill:var(--ui-art-muted)}.ui-art__muted-soft{fill:var(--ui-art-muted);opacity:.6}
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Spot art — @gemboss/ui 0.43.0. A small set of line-and-fill pictures for the places a screen needs a pause or has nothing yet
3
+ * (the Market redesign 21/10/2026, Chris: "toàn chữ là chữ là không được, phải có những khoảng nghỉ có hình ảnh"). Before this,
4
+ * `ui-empty` was a title and a sentence, and every surface that wanted a picture drew its own.
5
+ *
6
+ * Six pictures, one drawing style: the gem the brand is, in iris, with its tints behind it.
7
+ * gem-radar a gem inside rings, with stores around it (a briefing, "what Gem found")
8
+ * orbit you in the middle and the stores around you (competitors)
9
+ * people three heads (customers)
10
+ * page a page with a plus (nothing to compare yet, build one)
11
+ * plug two halves that are not joined (not connected)
12
+ * price a band, a middle and a dot (prices)
13
+ *
14
+ * Every colour is a token (`--gemboss-accent`, `-tint`, `-mid`, `-soft`), so the dark palette follows, and nothing in a picture is
15
+ * text. Decorative by default (`aria-hidden`): the words beside it say what it says. Pass `title` when the picture is the only thing
16
+ * that says it, and it becomes an image with that name. Server-renderable (no state, no effect). A page that writes the markup itself
17
+ * links `spot-art.css`; the shapes are in this file.
18
+ */
19
+ import "./spot-art.css";
20
+ import React, { type ReactNode } from "react";
21
+
22
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
23
+
24
+ export type SpotArtName = "gem-radar" | "orbit" | "people" | "page" | "plug" | "price";
25
+ export const SPOT_ART_NAMES: readonly SpotArtName[] = ["gem-radar", "orbit", "people", "page", "plug", "price"];
26
+
27
+ const ART: Record<SpotArtName, { w: number; h: number; body: ReactNode }> = {
28
+ "gem-radar": {
29
+ w: 220, h: 164,
30
+ body: (
31
+ <>
32
+ <circle className="ui-art__ring" cx="110" cy="84" r="72" />
33
+ <circle className="ui-art__dash" cx="110" cy="84" r="50" />
34
+ <path className="ui-art__line ui-art__soft" d="M110 38 78 70l32 50 32-50z" />
35
+ <path className="ui-art__line ui-art__thin" d="M78 70h64M110 38l-13 32 13 50 13-50z" />
36
+ <circle className="ui-art__line ui-art__paper" cx="36" cy="62" r="7" />
37
+ <circle className="ui-art__solid" cx="184" cy="112" r="9" />
38
+ <circle className="ui-art__mid" cx="170" cy="34" r="5" />
39
+ <circle className="ui-art__mid" cx="52" cy="126" r="4" />
40
+ </>
41
+ ),
42
+ },
43
+ orbit: {
44
+ w: 200, h: 150,
45
+ body: (
46
+ <>
47
+ <circle className="ui-art__ring" cx="100" cy="75" r="66" />
48
+ <circle className="ui-art__dash" cx="100" cy="75" r="42" />
49
+ <rect className="ui-art__solid" x="80" y="55" width="40" height="40" rx="12" />
50
+ <path className="ui-art__paper" d="M100 66 91 75l9 12 9-12z" />
51
+ <circle className="ui-art__line ui-art__paper" cx="40" cy="48" r="11" />
52
+ <circle className="ui-art__line ui-art__paper" cx="160" cy="62" r="11" />
53
+ <circle className="ui-art__mid" cx="132" cy="124" r="7" />
54
+ <circle className="ui-art__tint" cx="60" cy="118" r="5" />
55
+ </>
56
+ ),
57
+ },
58
+ people: {
59
+ w: 132, h: 96,
60
+ body: (
61
+ <>
62
+ <circle className="ui-art__solid" cx="66" cy="34" r="15" />
63
+ <path className="ui-art__solid" d="M38 88c0-17 12-28 28-28s28 11 28 28z" />
64
+ <circle className="ui-art__tint" cx="26" cy="42" r="11" />
65
+ <path className="ui-art__tint" d="M6 88c0-13 8-21 20-21 5 0 9 1 12 4-6 5-9 11-9 17z" />
66
+ <circle className="ui-art__mid" cx="106" cy="42" r="11" />
67
+ <path className="ui-art__mid" d="M126 88c0-13-8-21-20-21-5 0-9 1-12 4 6 5 9 11 9 17z" />
68
+ </>
69
+ ),
70
+ },
71
+ page: {
72
+ w: 128, h: 104,
73
+ body: (
74
+ <>
75
+ <rect className="ui-art__line ui-art__wash ui-art__mid-line" x="16" y="8" width="96" height="88" rx="12" />
76
+ <rect className="ui-art__tint" x="30" y="22" width="40" height="30" rx="6" />
77
+ <rect className="ui-art__mid" x="78" y="22" width="22" height="6" rx="3" />
78
+ <rect className="ui-art__tint" x="78" y="34" width="16" height="6" rx="3" />
79
+ <rect className="ui-art__tint" x="30" y="62" width="68" height="6" rx="3" />
80
+ <rect className="ui-art__soft" x="30" y="76" width="44" height="6" rx="3" />
81
+ <circle className="ui-art__solid" cx="104" cy="82" r="14" />
82
+ <path className="ui-art__plus" d="M98 82h12M104 76v12" />
83
+ </>
84
+ ),
85
+ },
86
+ plug: {
87
+ w: 132, h: 96,
88
+ body: (
89
+ <>
90
+ <rect className="ui-art__line ui-art__soft ui-art__mid-line" x="14" y="30" width="46" height="36" rx="10" />
91
+ <rect className="ui-art__line ui-art__paper ui-art__mid-line ui-art__dashed" x="72" y="30" width="46" height="36" rx="10" />
92
+ <path className="ui-art__plus ui-art__mid-line ui-art__dots" d="M60 48h12" />
93
+ <path className="ui-art__solid" d="M37 40 29 48l8 10 8-10z" />
94
+ <circle className="ui-art__muted" cx="95" cy="48" r="6" />
95
+ </>
96
+ ),
97
+ },
98
+ price: {
99
+ w: 140, h: 100,
100
+ body: (
101
+ <>
102
+ <rect className="ui-art__muted-soft" x="8" y="44" width="124" height="8" rx="4" />
103
+ <rect className="ui-art__tint" x="26" y="34" width="56" height="28" rx="10" />
104
+ <rect className="ui-art__solid" x="52" y="28" width="3" height="40" rx="1.5" />
105
+ <circle className="ui-art__ink" cx="108" cy="48" r="11" />
106
+ <circle className="ui-art__line ui-art__thin ui-art__paper-line" cx="108" cy="48" r="11" />
107
+ </>
108
+ ),
109
+ },
110
+ };
111
+
112
+ export interface SpotArtProps {
113
+ name: SpotArtName;
114
+ /** Makes the picture an image with this name, for the rare place where it is the only thing that says it. */
115
+ title?: string;
116
+ /** The width in px: the height follows. Default: the picture's own size. */
117
+ width?: number;
118
+ className?: string;
119
+ }
120
+
121
+ export function SpotArt({ name, title, width, className }: SpotArtProps) {
122
+ const art = ART[name];
123
+ if (!art) return null; // a name that got past the type (plain JS): draw nothing, not a broken box
124
+ return (
125
+ <svg className={cx("ui-art", className)} data-art={name} viewBox={`0 0 ${art.w} ${art.h}`} width={width ?? art.w} fill="none"
126
+ {...(title ? { role: "img", "aria-label": title } : { "aria-hidden": true })}>
127
+ {art.body}
128
+ </svg>
129
+ );
130
+ }