@gemboss/ui 0.25.0 → 0.30.2

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,165 @@
1
+ /**
2
+ * Two charts small enough to sit inside a card or a table cell — @gemboss/ui 0.30.0.
3
+ *
4
+ * `Sparkline`: the trend line every surface was drawing by hand. Counted 08/10/2026: the admin (intelligence `Spark`,
5
+ * analytics KPIs, operator), talent-radar trends, search-radar `Spark.jsx`, gemwatcher's pulse, content-radar's archive,
6
+ * gemform's form cards: six hand copies, three ways of choosing the baseline, one with gaps for a missing day.
7
+ * `ShareBar`: one bar split by share, e.g. a form's registrations by status (gemform). The kit's `ui-progress` is one
8
+ * value, `ui-barlist` one bar per row; neither says "how these N parts divide one whole" in one line.
9
+ *
10
+ * Server-renderable (no state, no effect). Colours are tokens (`--ui-spark-c`, `--c`), so the dark palette follows.
11
+ * ShareBar sizes its segments with an inline `flex-grow`: a plain-HTML page under a CSP that forbids inline style
12
+ * (gemcare: `style-src 'self'`) has to size them from its own stylesheet or script.
13
+ * A page that writes the markup itself (no React) computes the path with `sparklinePaths` (pure, `@gemboss/ui` index)
14
+ * or by hand, and links `minichart.css`.
15
+ */
16
+ import "./minichart.css"; // its own entry (0.30.0): a surface that draws no mini chart never downloads it
17
+ import React from "react";
18
+
19
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
20
+ const r1 = (n: number) => Math.round(n * 10) / 10;
21
+
22
+ /** A value, or a gap (`null` / `undefined` / NaN): a day with no reading breaks the line instead of drawing a zero. */
23
+ export type SparkPoint = number | null | undefined;
24
+
25
+ export interface SparklineGeometry {
26
+ width: number;
27
+ height: number;
28
+ /** One `d` per unbroken run of two or more points. */
29
+ lines: string[];
30
+ /** One closed `d` per run, down to the baseline (empty when `area` is off). */
31
+ areas: string[];
32
+ /** Points with no neighbour on either side: drawn as dots, a line of one point is invisible. */
33
+ dots: Array<[number, number]>;
34
+ /** The newest point, when the newest reading is a number (the "now" dot). */
35
+ last: [number, number] | null;
36
+ }
37
+
38
+ const isNum = (v: SparkPoint): v is number => typeof v === "number" && Number.isFinite(v);
39
+
40
+ /**
41
+ * The geometry of a sparkline, oldest point first, as SVG path data in a `width` x `height` box.
42
+ *
43
+ * The scale is honest by default: from 0 when every value is >= 0 (a count drawn from its own minimum turns 5, 4, 5
44
+ * into a cliff), otherwise from the smallest value. `min` / `max` fix it, e.g. a 30-day window that must not rescale.
45
+ * `invert` puts small values at the top (a rank: #1 is best).
46
+ */
47
+ export function sparklinePaths(points: readonly SparkPoint[], opts: {
48
+ width?: number; height?: number; pad?: number; min?: number; max?: number; invert?: boolean; area?: boolean;
49
+ } = {}): SparklineGeometry | null {
50
+ const { width = 120, height = 28, pad = 3, invert = false, area = true } = opts;
51
+ const vals = points.filter(isNum);
52
+ if (!vals.length) return null;
53
+ const lo = opts.min ?? (Math.min(...vals) >= 0 ? 0 : Math.min(...vals));
54
+ const hi = Math.max(opts.max ?? Math.max(...vals), lo + 1e-9);
55
+ const span = hi - lo || 1;
56
+ const n = points.length;
57
+ const x = (i: number) => r1(n === 1 ? width / 2 : pad + (i * (width - 2 * pad)) / (n - 1));
58
+ const y = (v: number) => {
59
+ const t = (Math.min(Math.max(v, lo), hi) - lo) / span;
60
+ return r1(invert ? pad + t * (height - 2 * pad) : height - pad - t * (height - 2 * pad));
61
+ };
62
+ // The area always fills DOWN to the bottom edge: under an inverted scale too, so the better (higher) a rank, the more
63
+ // ground under it; filled up to the top it read as shading above the line.
64
+ const base = height - pad;
65
+ const runs: Array<Array<[number, number]>> = [];
66
+ let cur: Array<[number, number]> | null = null;
67
+ // An index loop, not forEach: forEach skips the holes of a sparse array (`new Array(7)` with two days filled), and a
68
+ // hole is a day with no reading, which must break the line like `null` does.
69
+ for (let i = 0; i < n; i++) {
70
+ const v = points[i];
71
+ if (!isNum(v)) { cur = null; continue; }
72
+ if (!cur) { cur = []; runs.push(cur); }
73
+ cur.push([x(i), y(v)]);
74
+ }
75
+ const lines: string[] = [];
76
+ const areas: string[] = [];
77
+ const dots: Array<[number, number]> = [];
78
+ for (const run of runs) {
79
+ if (run.length === 1) { dots.push(run[0]); continue; }
80
+ const d = run.map(([px, py], j) => `${j ? "L" : "M"}${px} ${py}`).join(" ");
81
+ lines.push(d);
82
+ if (area) areas.push(`${d} L${run[run.length - 1][0]} ${base} L${run[0][0]} ${base} Z`);
83
+ }
84
+ const lastV = points[n - 1];
85
+ return { width, height, lines, areas, dots, last: isNum(lastV) ? [x(n - 1), y(lastV)] : null };
86
+ }
87
+
88
+ export type SparkTone = "neutral" | "series" | "success" | "critical";
89
+ // Whole class names, never built from a template: a class written out is a class grep (and the admin's orphan-rule gate,
90
+ // a-tap-target-rule-outlived-its-markup) can find.
91
+ const SPARK_TONE: Record<SparkTone, string> = { neutral: "", series: "ui-spark--series", success: "ui-spark--success", critical: "ui-spark--critical" };
92
+
93
+ /**
94
+ * A trend line. The line is muted ink and the newest point is one iris dot: the accent marks "now" and nothing else.
95
+ * `tone` colours the line for a series that means something (`success` / `critical`), `series` for iris.
96
+ * With `label` it is an image with that name (say what it shows: "Registrations, last 7 days: 3 then 5"); without it,
97
+ * it is decoration beside a number that already says it (aria-hidden).
98
+ */
99
+ export function Sparkline({ points, width = 120, height = 28, label, tone = "neutral", area = true, endDot = true, min, max, invert, className }: {
100
+ points: readonly SparkPoint[];
101
+ width?: number;
102
+ height?: number;
103
+ label?: string;
104
+ tone?: SparkTone;
105
+ area?: boolean;
106
+ endDot?: boolean;
107
+ min?: number;
108
+ max?: number;
109
+ invert?: boolean;
110
+ className?: string;
111
+ }) {
112
+ const g = sparklinePaths(points, { width, height, min, max, invert, area });
113
+ if (!g) return null;
114
+ const a11y = label ? { role: "img", "aria-label": label } : { "aria-hidden": true as const, focusable: "false" as const };
115
+ return (
116
+ <svg className={cx("ui-spark", SPARK_TONE[tone], className)} width={width} height={height}
117
+ viewBox={`0 0 ${width} ${height}`} {...a11y}>
118
+ {label ? <title>{label}</title> : null}
119
+ {g.areas.map((d) => <path key={`a${d}`} className="ui-spark__area" d={d} />)}
120
+ {g.lines.map((d) => <path key={`l${d}`} className="ui-spark__line" d={d} />)}
121
+ {g.dots.map(([cx_, cy]) => <circle key={`p${cx_}`} className="ui-spark__pt" cx={cx_} cy={cy} r={1.5} />)}
122
+ {endDot && g.last ? <circle className="ui-spark__end" cx={g.last[0]} cy={g.last[1]} r={2.5} /> : null}
123
+ </svg>
124
+ );
125
+ }
126
+
127
+ /** A part's colour: a status tone (the same five the kit's badges use) or a chart series. */
128
+ export type ShareTone = "neutral" | "info" | "success" | "warning" | "critical" | "series-1" | "series-2" | "series-3" | "series-4" | "series-5";
129
+
130
+ const SEG: Record<ShareTone, string> = {
131
+ neutral: "ui-sharebar__seg--neutral", info: "ui-sharebar__seg--info", success: "ui-sharebar__seg--success",
132
+ warning: "ui-sharebar__seg--warning", critical: "ui-sharebar__seg--critical", "series-1": "ui-sharebar__seg--series-1",
133
+ "series-2": "ui-sharebar__seg--series-2", "series-3": "ui-sharebar__seg--series-3", "series-4": "ui-sharebar__seg--series-4",
134
+ "series-5": "ui-sharebar__seg--series-5",
135
+ };
136
+ const SERIES: ShareTone[] = ["series-1", "series-2", "series-3", "series-4", "series-5"];
137
+
138
+ export interface SharePart {
139
+ label: string;
140
+ value: number;
141
+ /** Defaults to the series colours in order. */
142
+ tone?: ShareTone;
143
+ key?: string;
144
+ }
145
+
146
+ /**
147
+ * One bar, split by share. Parts at 0 take no room; an all-zero bar is the empty track. The bar is one image whose
148
+ * name lists every part ("Status: New 25, Contacted 2, Qualified 4"), so a screen reader hears the numbers the
149
+ * colours stand for; each segment's `title` says its own on hover.
150
+ */
151
+ export function ShareBar({ parts, label, className }: { parts: readonly SharePart[]; label?: string; className?: string }) {
152
+ const shown = parts.map((p, i) => ({ ...p, tone: p.tone ?? SERIES[i % 5] })).filter((p) => p.value > 0);
153
+ const total = shown.reduce((a, p) => a + p.value, 0);
154
+ const said = parts.map((p) => `${p.label} ${p.value}`).join(", ");
155
+ return (
156
+ <div className={cx("ui-sharebar", className)} role="img" aria-label={label ? `${label}: ${said}` : said}>
157
+ {shown.map((p) => (
158
+ // The SHARE, not the raw value: flex-grow values that add up to less than 1 hand out only that fraction of the
159
+ // track (0.2 + 0.3 filled half the bar). Shares always add up to 1.
160
+ <span key={p.key ?? p.label} className={cx("ui-sharebar__seg", SEG[p.tone])} style={{ flexGrow: +(p.value / total).toFixed(4) }}
161
+ title={`${p.label} ${p.value}`} />
162
+ ))}
163
+ </div>
164
+ );
165
+ }
@@ -0,0 +1 @@
1
+ .ui-setup,.ui-setup__inner{display:grid;gap:var(--ui-space-3, 12px)}.ui-setup__head,.ui-setup__row{display:flex;align-items:center;gap:var(--ui-space-3, 12px);list-style:none;cursor:pointer;border-radius:var(--ui-radius-md, 8px);color:inherit;transition:background-color .12s ease}.ui-setup__head::-webkit-details-marker,.ui-setup__row::-webkit-details-marker{display:none}div.ui-setup__head{cursor:auto}summary.ui-setup__head{padding:6px 8px;margin:-6px -8px 0}.ui-setup__title{margin:0;font-size:var(--ui-fs-body, 13px);line-height:20px;font-weight:650;color:var(--ui-text-strong, var(--ui-text))}.ui-setup__desc{margin:0;font-size:var(--ui-fs-body, 13px);line-height:20px;color:var(--ui-text-muted)}.ui-setup__row{padding:8px;margin:0 -8px}summary.ui-setup__head:hover,summary.ui-setup__row:hover{background:var(--ui-surface-2, #eceef2)}.ui-setup__row--static{cursor:auto}.ui-setup__row.ui-setup__row--static:after{content:none}summary.ui-setup__head:active,summary.ui-setup__row:active{background:var(--ui-border, #e5e7ea)}summary.ui-setup__head:focus-visible,summary.ui-setup__row:focus-visible{outline:2px solid var(--ui-focus, #5661f5);outline-offset:2px}summary.ui-setup__head:after,.ui-setup__row:after{content:"\25be";margin-left:auto;font-size:11px;color:var(--ui-text-muted);transition:transform .12s ease}.ui-setup:not([open])>summary.ui-setup__head:after,.ui-setup__step:not([open])>.ui-setup__row:after{transform:rotate(-90deg)}.ui-setup__bar{appearance:none;-webkit-appearance:none;display:block;width:100%;height:6px;border:0;border-radius:999px;overflow:hidden;background:var(--ui-border, #e5e7ea);color:var(--ui-accent, #1b1c1e)}.ui-setup__bar::-webkit-progress-bar{background:var(--ui-border, #e5e7ea);border-radius:inherit}.ui-setup__bar::-webkit-progress-value{background:currentColor;border-radius:inherit;transition:width .15s ease}.ui-setup__bar::-moz-progress-bar{background:currentColor;border-radius:inherit}.ui-setup__bar--done{color:var(--ui-success-fg, #007f5f)}.ui-setup__steps{display:grid;gap:2px}.ui-setup__mark{flex:0 0 16px;text-align:center;line-height:20px;color:var(--ui-text-muted)}.ui-setup__step[data-state=done] .ui-setup__mark{color:var(--ui-success-fg, #007f5f)}.ui-setup__label{min-width:0;overflow-wrap:anywhere;font-size:var(--ui-fs-body, 13px);line-height:20px;font-weight:550;color:var(--ui-text)}.ui-setup__step[data-state=done] .ui-setup__label{font-weight:450;color:var(--ui-text-muted)}.ui-setup__body{padding:4px 0 12px 28px;font-size:var(--ui-fs-body, 13px);line-height:20px;color:var(--ui-text)}.ui-setup__body>:first-child{margin-top:0}.ui-setup__body>:last-child{margin-bottom:0}@media (pointer: coarse),(max-width: 47.9975em){summary.ui-setup__head,.ui-setup__row{min-height:44px;box-sizing:border-box}}@media (prefers-reduced-motion: reduce){.ui-setup__head,.ui-setup__row,summary.ui-setup__head:after,.ui-setup__row:after,.ui-setup__bar::-webkit-progress-value{transition:none}}
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Setup guide — @gemboss/ui 0.30.0. The admin's "Set up your store" card (apps/admin StoreChecklistCard, Shopify's own
5
+ * Setup guide) as a component any surface can give its steps to: a title with the count, a bar, and collapsed step
6
+ * rows of which one is open. Six steps expanded is a wall; collapsed rows scan in a second and the open one is the next
7
+ * thing worth doing.
8
+ *
9
+ * Why it moved here (08/10/2026): gemform's Telegram alerts page drew its own numbered steps (1 The bot, 2 Where alerts
10
+ * go), the admin has this one in Polaris, and a third surface with a "connect this, then that" page would write a third.
11
+ *
12
+ * Native `<details>` all the way down, so a page that writes the markup by hand gets the same behaviour with no script:
13
+ * the guide is a `<details>` whose `<summary>` folds it away, each step is a `<details name>` of one group, which the
14
+ * browser keeps to one open at a time. The ROW reveals; what changes something is a button the surface puts in the
15
+ * step's body (one gesture, one meaning, as in the admin). A step with no body is a plain row, not a toggle that opens
16
+ * nothing.
17
+ *
18
+ * The step open at first is the first one not done, decided ONCE, like the admin: a step's state changing while the
19
+ * person reads it must not slam it shut (finishing step 1 leaves its confirmation on screen). The bar is a <progress>,
20
+ * not a width in a style attribute, so it fills under a CSP that forbids inline style.
21
+ */
22
+ import "./setup-guide.css"; // its own entry (0.30.0)
23
+ import React, { useEffect, useId, useRef, useState } from "react";
24
+
25
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
26
+
27
+ /** `unknown`: could not be read, so neither done nor to do; counted apart, never as progress. */
28
+ export type SetupState = "todo" | "done" | "unknown";
29
+
30
+ export interface SetupStep {
31
+ id: string;
32
+ title: React.ReactNode;
33
+ state?: SetupState;
34
+ /** What the open step shows: a sentence and the control that does it. Without one the row is not a toggle. */
35
+ body?: React.ReactNode;
36
+ }
37
+
38
+ const MARK: Record<SetupState, string> = { done: "✓", todo: "○", unknown: "?" };
39
+
40
+ export function SetupGuide({ title, description, steps, open, collapsible = true, rememberKey, group, labels, className }: {
41
+ title: React.ReactNode;
42
+ description?: React.ReactNode;
43
+ steps: readonly SetupStep[];
44
+ /** The step to show open at first; defaults to the first one not done. */
45
+ open?: string;
46
+ /** false: the head is a plain heading, the guide cannot be folded away. */
47
+ collapsible?: boolean;
48
+ /** Remember a folded guide in localStorage under this key, so someone not setting up today is not asked again on every
49
+ * visit. Give each place its own key (folding it on one page is not folding it everywhere). */
50
+ rememberKey?: string;
51
+ /** The steps' shared `name`. Defaults to a useId(), unique within one React root; pass one when two guides render in
52
+ * separate roots or separate static renders. */
53
+ group?: string;
54
+ labels?: { progress?: string; notMeasured?: (n: number) => string };
55
+ className?: string;
56
+ }) {
57
+ const auto = useId();
58
+ const name = group ?? `gb-setup${auto}`;
59
+ // Decided once (see the header): later renders keep whatever the person opened.
60
+ const [first] = useState(() => open ?? steps.find((s) => s.state !== "done" && s.body)?.id);
61
+ const guide = useRef<HTMLDetailsElement>(null);
62
+ useEffect(() => {
63
+ const el = guide.current;
64
+ if (!el || !rememberKey) return undefined;
65
+ try { if (localStorage.getItem(rememberKey) === "folded") el.open = false; } catch { /* storage blocked: stay open */ }
66
+ const onToggle = () => { try { localStorage.setItem(rememberKey, el.open ? "open" : "folded"); } catch { /* ignore */ } };
67
+ el.addEventListener("toggle", onToggle);
68
+ return () => el.removeEventListener("toggle", onToggle);
69
+ }, [rememberKey]);
70
+
71
+ if (!steps.length) return null; // nothing to set up is no guide, not a "0/0" card
72
+ const total = steps.length;
73
+ const done = steps.filter((s) => s.state === "done").length;
74
+ const unknown = steps.filter((s) => s.state === "unknown").length;
75
+ const all = done === total;
76
+ const head = (
77
+ <>
78
+ <h2 className="ui-setup__title">{title}</h2>
79
+ <span className={cx("ui-badge", all ? "ui-badge--success" : "ui-badge--warning")}>{`${done}/${total}`}</span>
80
+ {unknown > 0 ? <span className="ui-badge">{labels?.notMeasured?.(unknown) ?? `${unknown} not measured`}</span> : null}
81
+ </>
82
+ );
83
+ // One wrapper for everything under the head: a <details> lays out its summary and ONE content slot, so a gap set on
84
+ // the guide never reaches between the description, the bar and the steps (measured in Chromium 148).
85
+ const inner = (
86
+ <div className="ui-setup__inner">
87
+ {description ? <p className="ui-setup__desc">{description}</p> : null}
88
+ <progress className={cx("ui-setup__bar", all && "ui-setup__bar--done")} value={done} max={total}
89
+ aria-label={labels?.progress ?? "Setup progress"}>{`${done}/${total}`}</progress>
90
+ <div className="ui-setup__steps">
91
+ {steps.map((s) => {
92
+ const state = s.state ?? "todo";
93
+ const row = (as: "summary" | "div") => React.createElement(as, { className: cx("ui-setup__row", as === "div" && "ui-setup__row--static") },
94
+ <span className="ui-setup__mark" aria-hidden="true">{MARK[state]}</span>,
95
+ <span className="ui-setup__label">{s.title}</span>);
96
+ if (!s.body) return <div key={s.id} className="ui-setup__step" data-state={state}>{row("div")}</div>;
97
+ return (
98
+ <details key={s.id} className="ui-setup__step" name={name} open={s.id === first} data-state={state}>
99
+ {row("summary")}
100
+ <div className="ui-setup__body">{s.body}</div>
101
+ </details>
102
+ );
103
+ })}
104
+ </div>
105
+ </div>
106
+ );
107
+ if (!collapsible) {
108
+ return <section className={cx("ui-card ui-setup", className)}><div className="ui-setup__head">{head}</div>{inner}</section>;
109
+ }
110
+ return (
111
+ <details className={cx("ui-card ui-setup", className)} open ref={guide}>
112
+ <summary className="ui-setup__head">{head}</summary>
113
+ {inner}
114
+ </details>
115
+ );
116
+ }