@gemboss/ui 0.41.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.
- package/CHANGELOG.md +137 -0
- package/README.md +120 -10
- package/catalog/editor.html +9 -7
- package/catalog/index.html +157 -54
- package/gemboss-ui.css +3 -3
- package/package.json +6 -1
- package/parts/components.css +1 -1
- package/parts/core.css +2 -2
- package/parts/shell.css +1 -1
- package/src/assistant/engine.tsx +3 -2
- package/src/assistant.css +1 -1
- package/src/briefing-card.css +1 -0
- package/src/briefing-card.tsx +50 -0
- package/src/editor/controls.tsx +52 -8
- package/src/editor/index.ts +2 -2
- package/src/editor/swatch.ts +133 -9
- package/src/editor-plain.js +1 -1
- package/src/editor.css +1 -1
- package/src/gemboss-tokens.css +1 -1
- package/src/icons.tsx +2 -0
- package/src/index.ts +10 -4
- package/src/portal.tsx +12 -6
- package/src/progress-ring.css +1 -0
- package/src/progress-ring.tsx +65 -0
- package/src/question-card.css +1 -0
- package/src/question-card.tsx +116 -0
- package/src/range-bar.css +1 -0
- package/src/range-bar.tsx +102 -0
- package/src/reactions-data.ts +13 -0
- package/src/reactions.tsx +36 -10
- package/src/spot-art.css +1 -0
- package/src/spot-art.tsx +130 -0
- package/src/team-chat-data.ts +46 -13
- package/src/team-chat-plain.js +1 -1
- package/src/team-chat.css +1 -1
- package/src/team-chat.tsx +106 -9
- package/src/theme-dark.css +1 -1
package/src/portal.tsx
CHANGED
|
@@ -93,13 +93,15 @@ export type LeaderRow = {
|
|
|
93
93
|
|
|
94
94
|
const MEDALS = ["🥇", "🥈", "🥉"];
|
|
95
95
|
const PODIUM_SLOT = ["gb-podium-slot--1", "gb-podium-slot--2", "gb-podium-slot--3"];
|
|
96
|
+
/** What screen readers hear before a name ("Rank 1"); pass `rankLabel` in the page's language ("Hạng 1", "第 1 名"). */
|
|
97
|
+
const RANK_LABEL = (rank: number) => `Rank ${rank}`;
|
|
96
98
|
|
|
97
99
|
/**
|
|
98
100
|
* A ranked list of people. Rows are links when they carry `href`; `unit` is read after the count by
|
|
99
101
|
* screen readers ("163 votes") and can be shown with `showUnit`. Empty → nothing (a "top" list of
|
|
100
102
|
* zeros looks dead; GemCommunity hides it the same way).
|
|
101
103
|
*/
|
|
102
|
-
export function Leaderboard({ title, label, caption, rows, unit, showUnit = false, viewAll, as: L = "a", className, headingLevel = 2, id, currentLabel = "you" }: {
|
|
104
|
+
export function Leaderboard({ title, label, caption, rows, unit, showUnit = false, viewAll, as: L = "a", className, headingLevel = 2, id, currentLabel = "you", rankLabel = RANK_LABEL }: {
|
|
103
105
|
/** The visible heading. Leave it out on a page whose own <h1> already names the board; give `label`. */
|
|
104
106
|
title?: React.ReactNode; label?: string; caption?: React.ReactNode; rows: LeaderRow[]; unit?: string; showUnit?: boolean;
|
|
105
107
|
viewAll?: { href: string; label: React.ReactNode }; as?: LinkLike; className?: string; headingLevel?: 2 | 3;
|
|
@@ -107,6 +109,8 @@ export function Leaderboard({ title, label, caption, rows, unit, showUnit = fals
|
|
|
107
109
|
id?: string;
|
|
108
110
|
/** Read by screen readers after the viewer's own name ("Minh, you"). "" when the name already says it on screen. */
|
|
109
111
|
currentLabel?: string;
|
|
112
|
+
/** Read by screen readers before each name: `(n) => "Rank " + n` unless given. */
|
|
113
|
+
rankLabel?: (rank: number) => string;
|
|
110
114
|
}) {
|
|
111
115
|
if (!rows.length) return null;
|
|
112
116
|
const H = `h${headingLevel}` as "h2" | "h3";
|
|
@@ -125,7 +129,7 @@ export function Leaderboard({ title, label, caption, rows, unit, showUnit = fals
|
|
|
125
129
|
const inner = (
|
|
126
130
|
<>
|
|
127
131
|
<span className={cx("gb-leaderboard-rank", !MEDALS[i] && "is-number")}>
|
|
128
|
-
<span aria-hidden="true">{MEDALS[i] ?? i + 1}</span><span className="gb-sr-only">
|
|
132
|
+
<span aria-hidden="true">{MEDALS[i] ?? i + 1}</span><span className="gb-sr-only">{rankLabel(i + 1)}: </span>
|
|
129
133
|
</span>
|
|
130
134
|
<Avatar name={r.name} src={r.avatar} />
|
|
131
135
|
<span className="gb-leaderboard-name">{r.name}{r.current && currentLabel ? <span className="gb-sr-only">, {currentLabel}</span> : null}</span>
|
|
@@ -147,17 +151,19 @@ export function Leaderboard({ title, label, caption, rows, unit, showUnit = fals
|
|
|
147
151
|
);
|
|
148
152
|
}
|
|
149
153
|
|
|
150
|
-
/** The first three as a podium, drawn second · first · third. */
|
|
151
|
-
export function Podium({ rows, unit, as: L = "a", className
|
|
154
|
+
/** The first three as a podium, drawn second · first · third. `label` names the list for screen readers ("Top three"). */
|
|
155
|
+
export function Podium({ rows, unit, as: L = "a", className, label = "Top three", rankLabel = RANK_LABEL }: {
|
|
156
|
+
rows: LeaderRow[]; unit?: string; as?: LinkLike; className?: string; label?: string; rankLabel?: (rank: number) => string;
|
|
157
|
+
}) {
|
|
152
158
|
if (!rows.length) return null;
|
|
153
159
|
return (
|
|
154
|
-
<ol className={cx("gb-podium", className)} aria-label=
|
|
160
|
+
<ol className={cx("gb-podium", className)} aria-label={label}>
|
|
155
161
|
{[1, 0, 2].map((idx) => {
|
|
156
162
|
const r = rows[idx];
|
|
157
163
|
if (!r) return <li key={idx} className="gb-podium-slot is-empty" aria-hidden="true" />;
|
|
158
164
|
const body = (
|
|
159
165
|
<>
|
|
160
|
-
<span className="gb-podium-medal"><span aria-hidden="true">{MEDALS[idx]}</span><span className="gb-sr-only">
|
|
166
|
+
<span className="gb-podium-medal"><span aria-hidden="true">{MEDALS[idx]}</span><span className="gb-sr-only">{rankLabel(idx + 1)}: </span></span>
|
|
161
167
|
<Avatar name={r.name} src={r.avatar} size={56} />
|
|
162
168
|
<span className="gb-podium-name">{r.name}</span>
|
|
163
169
|
<span className="gb-podium-value">{r.value}{unit ? <span className="gb-podium-unit"> {unit}</span> : null}</span>
|
|
@@ -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-data.ts
CHANGED
|
@@ -23,6 +23,19 @@ const KEYS = new Map<string, string>(REACTION_GROUPS.flatMap(([, items]) => item
|
|
|
23
23
|
/** The quick pair shown on hover next to "Add reaction" (GemCare's 👍 Like and ✅ Done). */
|
|
24
24
|
export const QUICK_REACTIONS: readonly string[] = ["👍", "✅"];
|
|
25
25
|
|
|
26
|
+
/** The picker's "Frequently used" row holds this many. */
|
|
27
|
+
export const FREQUENT_MAX = 9;
|
|
28
|
+
/** What fills "Frequently used" until this browser has used that many (Chris 20/10/2026: GemCare's row always held nine, and a
|
|
29
|
+
* row of one or two for a new person read as empty). The quick pair first, then the reactions a team reaches for most. */
|
|
30
|
+
export const SUGGESTED_REACTIONS: readonly string[] = ["👍", "✅", "🙏", "🎉", "😂", "❤️", "👀", "🙌", "🔥"];
|
|
31
|
+
|
|
32
|
+
/** "Frequently used": what this browser used, most recent first, then the suggestions it has not used, up to FREQUENT_MAX. */
|
|
33
|
+
export function frequentReactions(recent: readonly string[]): string[] {
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
for (const e of [...recent, ...SUGGESTED_REACTIONS]) if (isReactionEmoji(e) && !out.includes(e) && out.length < FREQUENT_MAX) out.push(e);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
export function isReactionEmoji(e: unknown): e is string {
|
|
27
40
|
return typeof e === "string" && KEYS.has(e);
|
|
28
41
|
}
|
package/src/reactions.tsx
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* count does not), the viewer's own outlined and filled (not only coloured: two chips that differ only in hue look the
|
|
11
11
|
* same to a colour-blind reader). No chips, no row.
|
|
12
12
|
* - "Add reaction": opens the picker IN FLOW under the row (not floating: a layer in a scrolling column has to fight the
|
|
13
|
-
* bottom edge; a row in flow is scrolled to by the browser). Frequently used first (this browser's,
|
|
14
|
-
*
|
|
13
|
+
* bottom edge; a row in flow is scrolled to by the browser). Frequently used first (this browser's, filled up to nine
|
|
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.
|
|
@@ -20,7 +22,7 @@
|
|
|
20
22
|
import "./reactions.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
21
23
|
import React, { useEffect, useId, useRef, useState } from "react";
|
|
22
24
|
import { Icon } from "./icons";
|
|
23
|
-
import {
|
|
25
|
+
import { frequentReactions, REACTION_GROUPS, reactionLabel, reactionWords, searchReactions, type Reaction } from "./reactions-data";
|
|
24
26
|
|
|
25
27
|
const RECENT_KEY = "gemboss-ui:reactions-recent";
|
|
26
28
|
const RECENT_MAX = 9;
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
const [recent, setRecent] = useState<readonly string[]>(
|
|
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(() => {
|
|
99
|
-
|
|
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)}
|
|
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=
|
|
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">
|
package/src/spot-art.css
ADDED
|
@@ -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}
|
package/src/spot-art.tsx
ADDED
|
@@ -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
|
+
}
|