@gemboss/ui 0.10.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Collections (0.13.0): five of the six patterns Chris settled on 05/10/2026, as thin components over the classes of
3
+ * `@gemboss/ui/collections.css` (load it beside gemboss-ui.css). The sixth, `IndexTable`, keeps selection state and is
4
+ * in ./index-table (a client module). README "Collections" says which pattern to use when.
5
+ *
6
+ * DataTable compare values across items, column by column (reports, logs). Polaris DataTable.
7
+ * ResourceList read item by item: small media, a title, one or two lines, badges, one action. Polaris ResourceList.
8
+ * CompactList one line per item (a side rail, a popup, "needs attention"); `variant="activity"` is the timeline.
9
+ * CardGrid the picture is the content; auto-fill at 200 / 240 / 320, 16 apart. With MediaCard.
10
+ * Board items moving through fixed states; 12 cards a column, then "Show 24 more".
11
+ * CollectionState / CollectionSkeleton empty, no match, error, loading (in the shape of the pattern).
12
+ *
13
+ * A plain module: no hooks, no "use client", so a server component renders every one of these with any props (render
14
+ * functions included), and nothing the admin imports pulls it into its first load (the admin draws tables with
15
+ * Polaris; same reasoning as ./page). The markup is the same as the plain-HTML contract in the catalog, so a surface
16
+ * without React writes these classes by hand and gets the same thing.
17
+ */
18
+ import React from "react";
19
+
20
+ const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");
21
+
22
+ export type SortDirection = "ascending" | "descending";
23
+ export type CollectionSort = { key: string; direction: SortDirection };
24
+
25
+ /** A column of a DataTable or an IndexTable. Every field is plain data, so a server component can build it. */
26
+ export type CollectionColumn = {
27
+ key: string;
28
+ header: React.ReactNode;
29
+ /** Right-aligned tabular figures, header included (`is-num`). */
30
+ numeric?: boolean;
31
+ /** Where clicking the header goes to sort by this column (the order lives in the URL). */
32
+ sortHref?: string;
33
+ /** Sortable through `onSort` (a client parent) instead of a link. */
34
+ sortable?: boolean;
35
+ /** IndexTable below 768: "hide" leaves the cell out of the folded row; default shows it on the second line. */
36
+ condensed?: "sub" | "hide";
37
+ /** IndexTable below 768: the words in front of the value on the folded line ("Score"). */
38
+ label?: string;
39
+ /** Cap the cell at this width with an ellipsis (the whole text belongs in the cell's `title`). */
40
+ truncate?: number | string;
41
+ };
42
+
43
+ export type CollectionRow = { id: string; cells: Record<string, React.ReactNode> };
44
+
45
+ /** `aria-sort` for a column header: only on a sortable column; "none" when it is not the current sort. */
46
+ export function ariaSort(col: CollectionColumn, sort?: CollectionSort | null): React.AriaAttributes["aria-sort"] {
47
+ if (!col.sortHref && !col.sortable) return undefined;
48
+ return sort && sort.key === col.key ? sort.direction : "none";
49
+ }
50
+
51
+ /** The header cell's contents: the label, as a link (`sortHref`) or a button (`onSort`) when the column sorts. */
52
+ export function SortHeader({ col, sort, onSort }: { col: CollectionColumn; sort?: CollectionSort | null; onSort?: (key: string, direction: SortDirection) => void }) {
53
+ const next: SortDirection = sort && sort.key === col.key && sort.direction === "ascending" ? "descending" : "ascending";
54
+ if (col.sortHref) return <a className="ui-table__sort" href={col.sortHref}>{col.header}</a>;
55
+ if (col.sortable && onSort) return <button type="button" className="ui-table__sort" onClick={() => onSort(col.key, next)}>{col.header}</button>;
56
+ return <>{col.header}</>;
57
+ }
58
+
59
+ const truncStyle = (t: CollectionColumn["truncate"]): React.CSSProperties | undefined =>
60
+ t == null ? undefined : ({ ["--ui-trunc" as string]: typeof t === "number" ? `${t}px` : t } as React.CSSProperties);
61
+
62
+ export function Truncate({ children, title, width }: { children: React.ReactNode; title?: string; width?: number | string }) {
63
+ return <span className="ui-trunc" title={title} style={truncStyle(width)}>{children}</span>;
64
+ }
65
+
66
+ // ── 1 · DataTable ──────────────────────────────────────────────────────────────────────────────────────────────────
67
+
68
+ export type DataTableProps = {
69
+ columns: CollectionColumn[];
70
+ rows: CollectionRow[];
71
+ /** Names the table for a screen reader (a visually hidden caption). */
72
+ label: string;
73
+ sort?: CollectionSort | null;
74
+ onSort?: (key: string, direction: SortDirection) => void;
75
+ /** The first column stays put while the table scrolls sideways (what a data table does below 768). */
76
+ stickyFirstColumn?: boolean;
77
+ /** Rows 32 instead of 36: only for more than 20 rows. */
78
+ density?: "default" | "compact";
79
+ /** The first cell of every row is its header (`th scope="row"`). Default true: a data table's first column names the row. */
80
+ rowHeaders?: boolean;
81
+ totals?: Record<string, React.ReactNode>;
82
+ footer?: React.ReactNode;
83
+ /** loading → the skeleton in this table's shape; busy → the rows stay, dimmed; empty / no-match / error → `stateSlot`. */
84
+ state?: "ready" | "loading" | "busy" | "empty" | "no-match" | "error";
85
+ /** What to show for empty / no-match / error: a `CollectionState`. The header stays above it. */
86
+ stateSlot?: React.ReactNode;
87
+ className?: string;
88
+ };
89
+
90
+ export function DataTable({ columns, rows, label, sort, onSort, stickyFirstColumn, density = "default", rowHeaders = true, totals, footer, state = "ready", stateSlot, className }: DataTableProps) {
91
+ if (state === "loading") return <CollectionSkeleton pattern="table" columns={columns} label={label} />;
92
+ const showRows = state === "ready" || state === "busy";
93
+ return (
94
+ <div className={cx("ui-table-wrap", className)} aria-busy={state === "busy" ? true : undefined}>
95
+ <table className={cx("ui-table", stickyFirstColumn && "ui-table--sticky-first", density === "compact" && "ui-table--compact")}>
96
+ <caption className="ui-sr-only">{label}</caption>
97
+ <thead>
98
+ <tr>{columns.map((c) => <th key={c.key} scope="col" className={c.numeric ? "is-num" : undefined} aria-sort={ariaSort(c, sort)}><SortHeader col={c} sort={sort} onSort={onSort} /></th>)}</tr>
99
+ </thead>
100
+ {showRows ? (
101
+ <tbody>
102
+ {rows.map((r) => (
103
+ <tr key={r.id}>
104
+ {columns.map((c, i) => {
105
+ const body = c.truncate != null ? <span className="ui-trunc" style={truncStyle(c.truncate)}>{r.cells[c.key]}</span> : r.cells[c.key];
106
+ return i === 0 && rowHeaders
107
+ ? <th key={c.key} scope="row" className={c.numeric ? "ui-table__rowhead is-num" : "ui-table__rowhead"}>{body}</th>
108
+ : <td key={c.key} className={c.numeric ? "is-num" : undefined}>{body}</td>;
109
+ })}
110
+ </tr>
111
+ ))}
112
+ </tbody>
113
+ ) : null}
114
+ {totals && showRows ? (
115
+ <tfoot className="ui-table__totals"><tr>{columns.map((c) => <td key={c.key} className={c.numeric ? "is-num" : undefined}>{totals[c.key]}</td>)}</tr></tfoot>
116
+ ) : null}
117
+ </table>
118
+ {!showRows ? stateSlot : null}
119
+ {footer && showRows ? <div className="ui-table__footer">{footer}</div> : null}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ // ── 3 · ResourceList / ResourceItem ────────────────────────────────────────────────────────────────────────────────
125
+
126
+ export type ResourceItemProps = {
127
+ title: React.ReactNode;
128
+ /** The detail page. The WHOLE row then opens it; the title stays a real link. */
129
+ href?: string;
130
+ /** The link element: a Next `Link`, a router link. Default `<a>`. */
131
+ linkAs?: React.ElementType;
132
+ /** The full title, when it may be cut by the ellipsis. */
133
+ titleText?: string;
134
+ media?: React.ReactNode;
135
+ subtitle?: React.ReactNode;
136
+ meta?: React.ReactNode;
137
+ /** At most three are shown; the rest become "+N". */
138
+ badges?: React.ReactNode[];
139
+ aside?: React.ReactNode;
140
+ /** One button and/or a ⋯ `Menu`. Never more than one visible button. */
141
+ actions?: React.ReactNode;
142
+ selected?: boolean;
143
+ id?: string;
144
+ };
145
+
146
+ export function ResourceItem({ title, href, linkAs: Link = "a", titleText, media, subtitle, meta, badges, aside, actions, selected, id }: ResourceItemProps) {
147
+ const shown = (badges || []).slice(0, 3), more = (badges || []).length - shown.length;
148
+ return (
149
+ <li className={cx("ui-list__item", selected && "is-selected")} id={id} aria-selected={selected ? true : undefined}>
150
+ {media ? <div className="ui-list__media">{media}</div> : null}
151
+ <div className="ui-list__body">
152
+ {href ? <Link className="ui-list__title" href={href} title={titleText}>{title}</Link> : <span className="ui-list__title" title={titleText}>{title}</span>}
153
+ {subtitle ? <p className="ui-list__sub">{subtitle}</p> : null}
154
+ {meta ? <p className="ui-list__meta">{meta}</p> : null}
155
+ {shown.length ? <div className="ui-list__badges">{shown.map((b, i) => <React.Fragment key={i}>{b}</React.Fragment>)}{more > 0 ? <span className="ui-badge">+{more}</span> : null}</div> : null}
156
+ </div>
157
+ {aside != null ? <div className="ui-list__aside">{aside}</div> : null}
158
+ {actions ? <div className="ui-list__actions">{actions}</div> : null}
159
+ </li>
160
+ );
161
+ }
162
+
163
+ export type ResourceListProps = {
164
+ label: string;
165
+ children?: React.ReactNode;
166
+ /** Inside a card that is already the frame: no border, no shadow. */
167
+ flush?: boolean;
168
+ state?: "ready" | "loading" | "busy" | "empty" | "no-match" | "error";
169
+ stateSlot?: React.ReactNode;
170
+ /** How many placeholder rows while loading (the number expected, at most 10). */
171
+ loadingRows?: number;
172
+ /** The media shape of the loading rows. */
173
+ loadingMedia?: "avatar" | "thumbnail" | "none";
174
+ className?: string;
175
+ };
176
+
177
+ export function ResourceList({ label, children, flush, state = "ready", stateSlot, loadingRows = 5, loadingMedia = "thumbnail", className }: ResourceListProps) {
178
+ if (state === "loading") return <CollectionSkeleton pattern="list" rows={loadingRows} media={loadingMedia} label={label} flush={flush} />;
179
+ if (state === "empty" || state === "no-match" || state === "error") {
180
+ return <div className={cx("ui-list", flush && "ui-list--flush", className)} role="region" aria-label={label}>{stateSlot}</div>;
181
+ }
182
+ return <ul className={cx("ui-list", flush && "ui-list--flush", className)} aria-label={label} aria-busy={state === "busy" ? true : undefined}>{children}</ul>;
183
+ }
184
+
185
+ // ── 4 · CompactList ────────────────────────────────────────────────────────────────────────────────────────────────
186
+
187
+ export type CompactItem = {
188
+ id: string;
189
+ label: React.ReactNode;
190
+ href?: string;
191
+ onClick?: () => void;
192
+ icon?: React.ReactNode;
193
+ meta?: React.ReactNode;
194
+ current?: boolean;
195
+ /** activity: the line under the label, and the dot's tone. */
196
+ detail?: React.ReactNode;
197
+ time?: React.ReactNode;
198
+ dateTime?: string;
199
+ tone?: "success" | "warning" | "critical" | "info";
200
+ };
201
+
202
+ export function CompactList({ items, label, variant, linkAs: Link = "a", flush, className }: { items: CompactItem[]; label: string; variant?: "activity"; linkAs?: React.ElementType; flush?: boolean; className?: string }) {
203
+ const activity = variant === "activity";
204
+ return (
205
+ <ul className={cx("ui-list ui-list--compact", activity && "ui-list--activity", flush && "ui-list--flush", className)} aria-label={label}>
206
+ {items.map((it) => activity ? (
207
+ <li key={it.id} className="ui-list__item">
208
+ <span className={cx("ui-list__dot", it.tone && `ui-list__dot--${it.tone}`)} aria-hidden="true" />
209
+ <div>
210
+ {it.href ? <Link className="ui-list__label" href={it.href}>{it.label}</Link> : <span className="ui-list__label">{it.label}</span>}
211
+ {it.detail ? <span className="ui-list__detail">{it.detail}</span> : null}
212
+ </div>
213
+ {it.time ? <time className="ui-list__time" dateTime={it.dateTime}>{it.time}</time> : <span />}
214
+ </li>
215
+ ) : (
216
+ <li key={it.id} className="ui-list__item">
217
+ {React.createElement(it.href ? Link : it.onClick ? "button" : "div", {
218
+ className: cx("ui-list__row", it.current && "is-selected"),
219
+ ...(it.href ? { href: it.href, "aria-current": it.current ? "page" : undefined } : it.onClick ? { type: "button", onClick: it.onClick } : {}),
220
+ }, <>
221
+ {it.icon ? <span className="ui-list__icon" aria-hidden="true">{it.icon}</span> : null}
222
+ <span className="ui-list__label">{it.label}</span>
223
+ {it.meta != null ? <span className="ui-list__meta">{it.meta}</span> : null}
224
+ </>)}
225
+ </li>
226
+ ))}
227
+ </ul>
228
+ );
229
+ }
230
+
231
+ // ── 5 · CardGrid / MediaCard ───────────────────────────────────────────────────────────────────────────────────────
232
+
233
+ export type CardGridMin = 200 | 240 | 320;
234
+
235
+ export function CardGrid({ min = 240, label, children, className }: { min?: CardGridMin; label?: string; children?: React.ReactNode; className?: string }) {
236
+ return <div className={cx("ui-cardgrid", className)} data-min={min === 240 ? undefined : min} role={label ? "list" : undefined} aria-label={label}>{children}</div>;
237
+ }
238
+
239
+ export type MediaCardProps = {
240
+ title: React.ReactNode;
241
+ href?: string;
242
+ linkAs?: React.ElementType;
243
+ /** The picture (an `<img alt="">`): the title names it. null on a board card without one. */
244
+ media?: React.ReactNode;
245
+ meta?: React.ReactNode;
246
+ badges?: React.ReactNode[];
247
+ /** The ⋯ menu, in the picture's corner. */
248
+ menu?: React.ReactNode;
249
+ foot?: React.ReactNode;
250
+ /** 16/10 by default; 4/3 for a theme or a page. */
251
+ ratio?: "16/10" | "4/3";
252
+ /** Inside CardGrid with a `label`, each card is a list item. */
253
+ listItem?: boolean;
254
+ };
255
+
256
+ export function MediaCard({ title, href, linkAs: Link = "a", media, meta, badges, menu, foot, ratio = "16/10", listItem }: MediaCardProps) {
257
+ return (
258
+ <article className="ui-card ui-card--media" data-ratio={ratio === "4/3" ? "4/3" : undefined} role={listItem ? "listitem" : undefined}>
259
+ {media == null ? null : href ? <Link className="ui-card__media" href={href} tabIndex={-1} aria-hidden="true">{media}</Link> : <div className="ui-card__media">{media}</div>}
260
+ {menu ? <div className="ui-card__menu">{menu}</div> : null}
261
+ <div className="ui-card__body">
262
+ {href ? <Link className="ui-card__link" href={href}>{title}</Link> : <span className="ui-card__link">{title}</span>}
263
+ {meta ? <span className="ui-card__meta">{meta}</span> : null}
264
+ {badges && badges.length ? <div className="ui-list__badges">{badges.slice(0, 3).map((b, i) => <React.Fragment key={i}>{b}</React.Fragment>)}</div> : null}
265
+ </div>
266
+ {foot ? <div className="ui-card__foot">{foot}</div> : null}
267
+ </article>
268
+ );
269
+ }
270
+
271
+ // ── 6 · Board ──────────────────────────────────────────────────────────────────────────────────────────────────────
272
+
273
+ export type BoardColumn = {
274
+ id: string;
275
+ title: React.ReactNode;
276
+ tone?: "success" | "warning" | "critical" | "info";
277
+ /** The cards, already rendered (MediaCard, or a `ui-card ui-card--media` without a picture). */
278
+ cards: React.ReactNode[];
279
+ /** The total when the column holds more than it was given (a server that sent the first page). */
280
+ count?: number;
281
+ empty?: React.ReactNode;
282
+ };
283
+
284
+ /** 12 cards a column, then "Show 24 more" (nested `<details>`, no script): the GemFactory rule, now everyone's. */
285
+ export function Board({ columns, label, pageSize = 12, step = 24, moreLabel = (n) => `Show ${n} more`, emptyLabel = "Nothing here", className }:
286
+ { columns: BoardColumn[]; label: string; pageSize?: number; step?: number; moreLabel?: (n: number) => React.ReactNode; emptyLabel?: React.ReactNode; className?: string }) {
287
+ const rest = (cards: React.ReactNode[], from: number): React.ReactNode => {
288
+ if (from >= cards.length) return null;
289
+ const n = Math.min(step, cards.length - from);
290
+ return (
291
+ <details className="ui-board__more">
292
+ <summary className="ui-btn ui-btn--sm">{moreLabel(n)}</summary>
293
+ <ol className="ui-board__list">{cards.slice(from, from + n).map((c, i) => <li key={from + i}>{c}</li>)}</ol>
294
+ {rest(cards, from + n)}
295
+ </details>
296
+ );
297
+ };
298
+ return (
299
+ <div className={cx("ui-board", className)} role="region" aria-label={label}>
300
+ {columns.map((col) => (
301
+ <section key={col.id} className="ui-board__col" aria-labelledby={`ui-board-${col.id}`}>
302
+ <header className="ui-board__head">
303
+ <span className={cx("ui-board__dot", col.tone && `ui-board__dot--${col.tone}`)} aria-hidden="true" />
304
+ <span className="ui-board__name" id={`ui-board-${col.id}`}>{col.title}</span>
305
+ <span className="ui-board__count">{col.count ?? col.cards.length}</span>
306
+ </header>
307
+ {col.cards.length ? (
308
+ <>
309
+ <ol className="ui-board__list">{col.cards.slice(0, pageSize).map((c, i) => <li key={i}>{c}</li>)}</ol>
310
+ {rest(col.cards, pageSize)}
311
+ </>
312
+ ) : <p className="ui-board__empty">{col.empty ?? emptyLabel}</p>}
313
+ </section>
314
+ ))}
315
+ </div>
316
+ );
317
+ }
318
+
319
+ // ── States ─────────────────────────────────────────────────────────────────────────────────────────────────────────
320
+
321
+ export type CollectionStateProps = {
322
+ kind: "empty" | "no-match" | "error";
323
+ title: React.ReactNode;
324
+ body?: React.ReactNode;
325
+ /** empty: the ONE thing to do next (create, connect, run). no-match: "Clear filters". error: "Try again". */
326
+ action?: React.ReactNode;
327
+ /** error with data still on screen: when that data is from ("as of 14:05"). */
328
+ staleNote?: React.ReactNode;
329
+ };
330
+
331
+ /** Empty (nothing yet), no match (filters found nothing: the header and the filters stay), error (never an empty list). */
332
+ export function CollectionState({ kind, title, body, action, staleNote }: CollectionStateProps) {
333
+ if (kind === "error") {
334
+ return (
335
+ <div className="ui-notice ui-notice--danger ui-coll-error" role="alert">
336
+ <div><b>{title}</b>{body ? <> {body}</> : null}{staleNote ? <span className="ui-muted"> {staleNote}</span> : null}</div>
337
+ {action}
338
+ </div>
339
+ );
340
+ }
341
+ return (
342
+ <div className={cx("ui-empty", kind === "no-match" && "ui-empty--no-match")}>
343
+ <p className="ui-empty__title">{title}</p>
344
+ {body ? <p className="ui-empty__body">{body}</p> : null}
345
+ {action}
346
+ </div>
347
+ );
348
+ }
349
+
350
+ export type SkeletonPattern = "table" | "index" | "list" | "compact" | "grid" | "board";
351
+
352
+ /**
353
+ * A placeholder in the shape of the pattern: a table keeps its real header and gets 8px bars in the cells, a list a
354
+ * media box and two bars, a grid a picture and two bars. `rows` = the number of rows expected (at most 10). The bars'
355
+ * sheen stops under prefers-reduced-motion (ui.css). The whole block is one status for a screen reader.
356
+ */
357
+ export function CollectionSkeleton({ pattern, rows = 5, columns = [], media = "thumbnail", label = "Loading", flush, loadingText = "Loading…", check, actions }:
358
+ { pattern: SkeletonPattern; rows?: number; columns?: CollectionColumn[]; media?: "avatar" | "thumbnail" | "none"; label?: string; flush?: boolean; loadingText?: string;
359
+ /** index: the checkbox and actions columns the real table will have, so no column moves when rows arrive. */ check?: boolean; actions?: boolean }) {
360
+ const n = Math.max(1, Math.min(10, rows));
361
+ const line = (w?: string) => <span className="ui-skeleton__line" style={w ? { width: w } : undefined} />;
362
+ const status = <span className="ui-sr-only" role="status">{loadingText}</span>;
363
+ if (pattern === "table" || pattern === "index") {
364
+ const cols = columns.length ? columns : [{ key: "a", header: "" }, { key: "b", header: "" }, { key: "c", header: "" }];
365
+ return (
366
+ <div className={cx("ui-table-wrap ui-skeleton--table", pattern === "index" && "ui-index")} aria-busy="true">
367
+ {status}
368
+ <table className={cx("ui-table", pattern === "index" && "ui-table--index")} aria-hidden="true">
369
+ <thead><tr>{check ? <th className="ui-table__check"><span className="ui-table__checkbox" /></th> : null}{cols.map((c) => <th key={c.key} className={c.numeric ? "is-num" : undefined}>{c.header}</th>)}{actions ? <th className="ui-table__actions" /> : null}</tr></thead>
370
+ <tbody>{Array.from({ length: n }, (_, i) => <tr key={i}>{check ? <td className="ui-table__check"><span className="ui-table__checkbox" /></td> : null}{cols.map((c) => <td key={c.key}>{line()}</td>)}{actions ? <td className="ui-table__actions">{line("40px")}</td> : null}</tr>)}</tbody>
371
+ </table>
372
+ </div>
373
+ );
374
+ }
375
+ if (pattern === "list" || pattern === "compact") {
376
+ return (
377
+ <div aria-busy="true">
378
+ {status}
379
+ <ul className={cx("ui-list", pattern === "list" ? "ui-skeleton--list" : "ui-list--compact ui-skeleton--compact", flush && "ui-list--flush")} aria-hidden="true">
380
+ {Array.from({ length: n }, (_, i) => pattern === "list" ? (
381
+ <li key={i} className="ui-list__item">
382
+ {media !== "none" ? <span className={cx("ui-skeleton__block", media === "avatar" && "ui-skeleton__block--round")} /> : null}
383
+ <span className="ui-list__body">{line()}{line()}</span>
384
+ </li>
385
+ ) : (
386
+ <li key={i} className="ui-list__item"><span className="ui-list__row"><span className="ui-skeleton__block" style={{ width: 16, height: 16 }} />{line()}</span></li>
387
+ ))}
388
+ </ul>
389
+ </div>
390
+ );
391
+ }
392
+ if (pattern === "grid") {
393
+ return (
394
+ <div aria-busy="true">
395
+ {status}
396
+ <div className="ui-cardgrid ui-skeleton--grid" aria-hidden="true">
397
+ {Array.from({ length: n }, (_, i) => (
398
+ <div key={i} className="ui-card ui-card--media"><span className="ui-card__media" /><span className="ui-card__body">{line()}{line()}</span></div>
399
+ ))}
400
+ </div>
401
+ </div>
402
+ );
403
+ }
404
+ return (
405
+ <div aria-busy="true">
406
+ {status}
407
+ <div className="ui-board ui-skeleton--board" aria-hidden="true">
408
+ {Array.from({ length: Math.min(n, 6) }, (_, i) => (
409
+ <div key={i} className="ui-board__col"><span className="ui-board__head">{line("40%")}</span><ol className="ui-board__list"><li /><li /><li /></ol></div>
410
+ ))}
411
+ </div>
412
+ </div>
413
+ );
414
+ }
package/src/editor.css CHANGED
@@ -28,6 +28,9 @@
28
28
  @keyframes gemboss-editor-enter { from { opacity: 0; } to { opacity: 1; } }
29
29
  @keyframes gemboss-editor-toast-in { from { opacity: 0; transform: translate(-50%, -8px); } to { opacity: 1; transform: translate(-50%, 0); } }
30
30
  @keyframes gemboss-pagepicker-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
31
+ /* The top bar's menus (⋯ More, Publish ▾). Named by `.gemboss-editor-menu` since before 0.9.0 but never defined, so they
32
+ opened with no motion while the page picker beside them slid in: the same entry as the picker's. */
33
+ @keyframes gemboss-flyout-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
31
34
  @keyframes gemboss-statusbar-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
32
35
 
33
36
  /* Editor is position:fixed and self-contained, so main needs no height/padding —
@@ -206,8 +209,11 @@
206
209
  z-index: 500; /* above the editor (z-index 400) */
207
210
  min-width: 210px;
208
211
  padding: 6px;
209
- animation: gemboss-flyout-in var(--gemboss-transition-fast) ease both;
212
+ /* No fill mode: once it has landed the menu holds no animated value (a `both` fill left an identity transform on it). */
213
+ animation: gemboss-flyout-in var(--gemboss-transition-fast) ease;
210
214
  }
215
+ @media (prefers-reduced-motion: reduce) {
216
+ .gemboss-editor-menu { animation: none; } }
211
217
  .gemboss-editor-menu-item {
212
218
  display: flex;
213
219
  align-items: center;
@@ -244,9 +250,9 @@
244
250
  and frame tightly and the preview's top gap matches its gap to the chat sidebar. */
245
251
  .gemboss-editor-body { flex: 1; min-height: 0; display: flex; gap: 0; padding: var(--gemboss-editor-inset); box-sizing: border-box; }
246
252
  /* Both panels become rounded, bordered cards (design-system surface). The preview's direct
247
- child is the .gemboss-editor-preview-pane wrapper (it positions the section-outline overlay),
253
+ child is the .gemboss-editor-preview-pane wrapper (it positions the overlays: Global styles, the score panel),
248
254
  so the card styling lives on the WRAPPER — `overflow: hidden` also clips the iframe + the
249
- outline panel to the rounded corners. (Targeting .gemboss-pv here would miss: it's now a
255
+ overlays to the rounded corners. (Targeting .gemboss-pv here would miss: it's now a
250
256
  grandchild inside the wrapper.) */
251
257
  .gemboss-editor-body > .gemboss-ec,
252
258
  .gemboss-editor-body > .gemboss-editor-preview-pane {
@@ -260,19 +266,15 @@
260
266
  never reached the screen and the theme editor's copy of the row carried no class at all. The look below is that
261
267
  inline style, written as CSS with the same tokens: the same computed values, one owner.
262
268
  The LOOK hangs on the row (`.gemboss-ec-tabs > [role="tab"]`); `.gemboss-ec-tab` is what the pointer blocks size
263
- (44px under touch here, the 44px overlay under a mouse in chat.css). The theme editor's tabs wear the look but not
264
- `.gemboss-ec-tab` yet, so they stay 36px under touch: adding it is a visible change, left for its own decision. */
265
- /* OFF THE SPACE SCALE, KNOWINGLY: the row's 10px sides and the tab's 7px padding are the inline style's numbers, carried
266
- unchanged so the admin does not move a pixel (the typography gate never read them while they were inline strings).
267
- Snapping to the scale (12px, 8px) makes the row 4px wider inside and each tab 2px taller: a visible change, left to
268
- its own decision. Named here so it is one edit, not a hunt. */
269
+ (44px under touch here, the 44px overlay under a mouse in chat.css). Every editor's tabs wear it: the theme editor's
270
+ did not until 05/10/2026 and stayed 36px under touch while the page and section editors' were 44.
271
+ Spacing is the scale (`--ui-space-*`, ui.css): the inline style's 10px row sides and 7px tab padding became 12 and 8
272
+ on 05/10/2026, so under a mouse each tab is 2px taller and the row 4px narrower inside. */
269
273
  .gemboss-ec-tabs {
270
- --gemboss-ec-tabs-pad-x: 10px;
271
- --gemboss-ec-tab-pad: 7px;
272
- display: flex; gap: 4px; padding: 8px var(--gemboss-ec-tabs-pad-x); border-bottom: 1px solid var(--gemboss-divider); flex: none;
274
+ display: flex; gap: var(--ui-space-1); padding: var(--ui-space-2) var(--ui-space-3); border-bottom: 1px solid var(--gemboss-divider); flex: none;
273
275
  }
274
276
  .gemboss-ec-tabs > [role="tab"] {
275
- flex: 1; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; padding: var(--gemboss-ec-tab-pad); border-radius: 8px;
277
+ flex: 1; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; padding: var(--ui-space-2); border-radius: 8px;
276
278
  display: flex; align-items: center; justify-content: center; gap: 6px;
277
279
  border: 1px solid transparent; background: transparent; color: var(--gemboss-text-muted); box-shadow: none;
278
280
  }
@@ -754,10 +756,11 @@ body.ui-resizing .gemboss-editor-body .gemboss-pv { will-change: flex-basis; }
754
756
  .gemboss-pageedit-scope{display:none}}
755
757
  .gemboss-pageedit-pv{flex:1;min-width:0;background:var(--gemboss-preview-canvas);display:flex;justify-content:center;overflow:auto;position:relative}
756
758
  .gemboss-pageedit-pv .gemboss-pv-frame iframe{width:100%;height:100%;border:0;background:#fff;display:block}
757
- .gemboss-pageedit-chip{display:flex;align-items:center;gap:8px;margin:0 12px 8px;padding:6px 12px;border-radius:8px;background:rgba(86,97,245,.1);border:1px solid rgba(86,97,245,.3);font-size:12px;color:#3a44de}
759
+ /* Neutral like every other selected thing in the admin (`--gemboss-active-*`), not iris: iris is for focus and links. */
760
+ .gemboss-pageedit-chip{display:flex;align-items:center;gap:8px;margin:0 12px 8px;padding:6px 12px;border-radius:8px;background:var(--gemboss-active-bg);border:1px solid var(--gemboss-divider);font-size:12px;color:var(--gemboss-active-fg)}
758
761
  .gemboss-pageedit-chip strong{text-transform:capitalize}
759
- .gemboss-pageedit-chip button{margin-left:auto;border:0;background:transparent;color:#3a44de;cursor:pointer;font-size:12px}
760
- /* Copy a winning test shipped onto this page. Amber, not the editor's indigo: this is not a mode the
762
+ .gemboss-pageedit-chip button{margin-left:auto;border:0;background:transparent;color:var(--gemboss-active-fg);cursor:pointer;font-size:12px}
763
+ /* Copy a winning test shipped onto this page. Amber, not the selection chip's neutral: this is not a mode the
761
764
  seller turned on, it is a difference between what they are reading and what shoppers are served. */
762
765
  .gemboss-pageedit-standing{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:8px 16px;background:rgba(176,125,24,.09);border-bottom:1px solid rgba(176,125,24,.28);font-size:13px;line-height:1.5;color:#7a5710}
763
766
  .gemboss-pageedit-standing code{font-size:12px;background:rgba(176,125,24,.14);border-radius:4px;padding:2px 4px}
@@ -771,7 +774,7 @@ body.ui-resizing .gemboss-editor-body .gemboss-pv { will-change: flex-basis; }
771
774
  .gemboss-layers-list{flex:1;min-height:0;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column;gap:4px}
772
775
  .gemboss-layer{display:flex;align-items:center;gap:4px;border:1px solid transparent;border-radius:8px;padding:0 4px 0 6px}
773
776
  .gemboss-layer:hover{background:var(--gemboss-hover,rgba(0,0,0,.04))}
774
- .gemboss-layer.is-sel{border-color:var(--gemboss-primary, #1b1c1e);background:rgba(86,97,245,.06)}
777
+ .gemboss-layer.is-sel{border-color:var(--gemboss-primary, #1b1c1e);background:var(--gemboss-active-bg)}
775
778
  .gemboss-layer-main{flex:1;min-width:0;display:flex;flex-direction:column;align-items:flex-start;gap:1px;background:transparent;border:0;cursor:pointer;text-align:left;padding:8px 4px}
776
779
  .gemboss-layer-type{font-size:13px;font-weight:600;color:var(--gemboss-text,#1b1c1e);text-transform:capitalize}
777
780
  .gemboss-layer-head{font-size:var(--gemboss-fs-caption);color:var(--gemboss-text-muted,#686b71);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:195px}
@@ -1043,11 +1046,11 @@ select.gemboss-ei-inp{appearance:none;background-image:linear-gradient(45deg,tra
1043
1046
  .gemboss-guest-signin { font-size: var(--gemboss-fs-body); font-weight: 600; color: var(--gemboss-text-muted); text-decoration: none; }
1044
1047
  .gemboss-guest-signin:hover { color: var(--gemboss-text); }
1045
1048
  .gemboss-guest-keep {
1046
- border: 0; border-radius: 999px; background: #5661f5; color: #fff; cursor: pointer;
1049
+ border: 0; border-radius: 999px; background: var(--gemboss-primary); color: var(--gemboss-on-primary); cursor: pointer;
1047
1050
  font: inherit; font-size: var(--gemboss-fs-caption); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
1048
1051
  padding: 8px 20px; white-space: nowrap;
1049
1052
  }
1050
- .gemboss-guest-keep:hover { background: #3a44de; }
1053
+ .gemboss-guest-keep:hover { background: var(--gemboss-primary-dark); }
1051
1054
  @media (max-width: 720px) {
1052
1055
  .gemboss-guest-note { display: none; }
1053
1056
  }
@@ -79,7 +79,7 @@
79
79
  and reading as "the font keeps shrinking". New rule: pick a step here, never a
80
80
  raw px. Floor is 12px (--fs-caption); 11px (--fs-micro) is reserved for bold
81
81
  micro-badges/count-pills ONLY — nothing below that. Body default is 14px, one
82
- step up from Polaris bodyMd (13px; bodySm is 12) which the app had been leaning on for prose. The kit (ui.css) reads --ui-fs-body, 13px, because it sits beside Polaris components. */
82
+ step up from Polaris bodyMd (13px; bodySm is 12) which the app had been leaning on for prose. The kit (ui.css, kit-plain.css) reads --ui-fs-body, 13px, because it sits beside Polaris components; since 0.12.0 it reads none of body/h1-h3 here (README "Admin density"). */
83
83
  --gemboss-fs-micro: 11px; /* bold count-pills / status chips only */
84
84
  --gemboss-fs-caption: 12px; /* metadata, timestamps, secondary labels */
85
85
  --gemboss-fs-body: 14px; /* default body / reading text */