@gemboss/ui 0.21.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/README.md +433 -24
  3. package/catalog/editor.html +426 -10
  4. package/catalog/index.html +907 -33
  5. package/gemboss-ui.css +3 -3
  6. package/package.json +23 -3
  7. package/parts/components.css +5 -0
  8. package/parts/core.css +5 -0
  9. package/parts/shell.css +5 -0
  10. package/src/action-card.css +1 -0
  11. package/src/action-card.tsx +149 -0
  12. package/src/app-shell.css +1 -1
  13. package/src/assistant/chat-markdown.ts +279 -0
  14. package/src/assistant/chat-turns.ts +120 -0
  15. package/src/assistant/chat.tsx +871 -0
  16. package/src/assistant/engine-mark-art.ts +45 -0
  17. package/src/assistant/engine-mark.tsx +39 -0
  18. package/src/assistant/engine.tsx +697 -0
  19. package/src/assistant/host.tsx +3 -1
  20. package/src/assistant/index.ts +6 -0
  21. package/src/assistant-chat.css +1 -0
  22. package/src/assistant-engine.css +1 -0
  23. package/src/auth.css +1 -1
  24. package/src/auth.tsx +18 -2
  25. package/src/chat-composer.css +1 -1
  26. package/src/chat-plain.js +6 -0
  27. package/src/chat.css +1 -1
  28. package/src/collections.tsx +1 -0
  29. package/src/dialog.tsx +3 -135
  30. package/src/editor/controls.tsx +1 -0
  31. package/src/editor/frame.tsx +12 -4
  32. package/src/editor/index.ts +10 -1
  33. package/src/editor/layers.tsx +136 -0
  34. package/src/editor/panel.tsx +1 -0
  35. package/src/editor/panes.tsx +4 -3
  36. package/src/editor/sortable.tsx +79 -0
  37. package/src/editor/tree.tsx +61 -0
  38. package/src/editor-layers-plain.js +1 -0
  39. package/src/editor.css +1 -1
  40. package/src/engine-plain.js +1 -0
  41. package/src/index-table.tsx +1 -0
  42. package/src/index.ts +8 -3
  43. package/src/kit-plain.css +1 -1
  44. package/src/kit-plain.js +1 -1
  45. package/src/launcher.tsx +1 -0
  46. package/src/menu-layer.ts +9 -2
  47. package/src/minichart.css +1 -0
  48. package/src/minichart.tsx +165 -0
  49. package/src/portal.tsx +1 -0
  50. package/src/reactions.tsx +1 -0
  51. package/src/resource-list-select.tsx +1 -0
  52. package/src/search-dialog.tsx +146 -0
  53. package/src/selection.tsx +1 -0
  54. package/src/setup-guide.css +1 -0
  55. package/src/setup-guide.tsx +116 -0
  56. package/src/theme-script.ts +10 -0
  57. package/src/theme.tsx +5 -5
@@ -0,0 +1,146 @@
1
+ "use client";
2
+
3
+ /**
4
+ * `SearchDialog` — @gemboss/ui (04/10/2026; its own module since 0.23.0, out of dialog.tsx).
5
+ *
6
+ * The Featurebase / admin command search. A topbar button (or ⌘K / Ctrl+K / "/")
7
+ * opens it; what to show before typing comes from `initial`, results while typing from `search`
8
+ * (debounced, the latest answer wins); ↑ ↓ move, Enter opens, Esc closes. Results are links, so a
9
+ * page without JavaScript loses only the popup, never the search itself (`fallbackHref`).
10
+ * Its look (`gb-search-*`) is in portal.css, which it imports: a surface that never shows it never loads it.
11
+ */
12
+ import "./portal.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
13
+ import React, { useCallback, useEffect, useId, useRef, useState } from "react";
14
+ import { Icon } from "./icons";
15
+
16
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
17
+
18
+ export type SearchItem = { href: string; title: string; meta?: React.ReactNode };
19
+ export type SearchSection = { title: string; items: SearchItem[] };
20
+
21
+ // One constant, not a default `= []`: a fresh array each render, read by an effect, re-ran it forever
22
+ // ("Maximum update depth exceeded", review of 0.7.0 in Chromium).
23
+ const NO_SECTIONS: SearchSection[] = [];
24
+
25
+ export function SearchDialog({ search, initial = NO_SECTIONS, placeholder = "Search…", label = "Search", fallbackHref, shortcut = true, renderItem }: {
26
+ /** Results for a query. Called at most every 150 ms; an answer to an older query is dropped. */
27
+ search: (q: string) => Promise<SearchSection[]>;
28
+ /** What the popup shows before anything is typed (Featurebase: "Trending requests", "Latest updates"). */
29
+ initial?: SearchSection[];
30
+ placeholder?: string;
31
+ label?: string;
32
+ /** Where the topbar button goes without JavaScript (a search page), used as given. */
33
+ fallbackHref?: string;
34
+ /** ⌘K / Ctrl+K and "/" open it. */
35
+ shortcut?: boolean;
36
+ renderItem?: (item: SearchItem) => React.ReactNode;
37
+ }) {
38
+ const [open, setOpen] = useState(false);
39
+ const [q, setQ] = useState("");
40
+ const [sections, setSections] = useState<SearchSection[]>(initial);
41
+ const [active, setActive] = useState(0);
42
+ const [busy, setBusy] = useState(false);
43
+ const ref = useRef<HTMLDialogElement>(null);
44
+ const input = useRef<HTMLInputElement>(null);
45
+ const seq = useRef(0);
46
+ const listId = useId();
47
+ const flat = sections.flatMap((s) => s.items);
48
+ // The latest `search` and `initial`, read when needed: a consumer's inline arrow or array (a new one
49
+ // each render) must not restart the debounce or the effect.
50
+ const searchRef = useRef(search);
51
+ const initialRef = useRef(initial);
52
+ searchRef.current = search;
53
+ initialRef.current = initial;
54
+
55
+ const show = useCallback(() => {
56
+ const d = ref.current;
57
+ if (d && !d.open) { d.showModal(); setOpen(true); requestAnimationFrame(() => input.current?.focus()); }
58
+ }, []);
59
+ useEffect(() => {
60
+ if (!shortcut) return undefined;
61
+ const onKey = (e: KeyboardEvent) => {
62
+ const t = e.target as HTMLElement | null;
63
+ const typing = !!t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
64
+ // A handler that already took the key (the admin's own ⌘K palette) keeps it.
65
+ if (e.defaultPrevented) return;
66
+ if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || (e.key === "/" && !typing)) { e.preventDefault(); show(); }
67
+ };
68
+ document.addEventListener("keydown", onKey);
69
+ return () => document.removeEventListener("keydown", onKey);
70
+ }, [shortcut, show]);
71
+ useEffect(() => {
72
+ if (!open) return undefined;
73
+ const query = q.trim();
74
+ // Every change takes a new number: an answer still on its way for an older query is dropped,
75
+ // including when the box was just cleared (it used to land under an empty box).
76
+ const mine = ++seq.current;
77
+ if (!query) { setSections(initialRef.current); setActive(0); setBusy(false); return undefined; }
78
+ setBusy(true);
79
+ const t = setTimeout(async () => {
80
+ try {
81
+ const res = await searchRef.current(query);
82
+ if (mine === seq.current) { setSections(res); setActive(0); }
83
+ } catch {
84
+ if (mine === seq.current) setSections(NO_SECTIONS);
85
+ } finally {
86
+ if (mine === seq.current) setBusy(false);
87
+ }
88
+ }, 150);
89
+ return () => { clearTimeout(t); if (mine === seq.current) setBusy(false); };
90
+ }, [q, open]);
91
+ // Before anything is typed, show the latest suggestions the page passed.
92
+ useEffect(() => { if (open && !q.trim()) setSections(initial); }, [initial, open, q]);
93
+ // The highlighted result stays in view as the arrows move it.
94
+ useEffect(() => {
95
+ if (!open) return;
96
+ document.getElementById(`${listId}-${active}`)?.scrollIntoView({ block: "nearest" });
97
+ }, [active, open, listId]);
98
+
99
+ const go = (item: SearchItem | undefined) => { if (item) window.location.assign(item.href); };
100
+ let n = -1;
101
+ return (
102
+ <>
103
+ <a className="gb-search-trigger" href={fallbackHref ?? "#"} aria-label={label} aria-haspopup="dialog"
104
+ onClick={(e) => { e.preventDefault(); show(); }}>
105
+ <Icon name="search" size={18} />
106
+ </a>
107
+ <dialog ref={ref} className="ui-modal gb-search" aria-label={label}
108
+ onClose={() => { setOpen(false); setQ(""); }}
109
+ onClick={(e) => { if (e.target === e.currentTarget) e.currentTarget.close(); }}>
110
+ <div className="gb-search-box">
111
+ <Icon name="search" size={18} className="gb-search-icon" />
112
+ <input ref={input} className="gb-search-input" type="search" placeholder={placeholder} aria-label={label}
113
+ value={q} onChange={(e) => setQ(e.target.value)} role="combobox" aria-expanded="true" aria-controls={listId}
114
+ aria-activedescendant={flat[active] ? `${listId}-${active}` : undefined} autoComplete="off"
115
+ onKeyDown={(e) => {
116
+ if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, flat.length - 1)); }
117
+ else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
118
+ // Enter that confirms an IME composition (Vietnamese, Chinese) is not "open".
119
+ else if (e.key === "Enter" && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault(); go(flat[active]); }
120
+ }} />
121
+ </div>
122
+ <p className="gb-search-empty" aria-live="polite">
123
+ {sections.every((s) => !s.items.length) && q.trim() ? (busy ? "Searching…" : "Nothing matches.") : ""}
124
+ </p>
125
+ <div className="gb-search-results" id={listId} role="listbox" aria-busy={busy}>
126
+ {sections.filter((s) => s.items.length).map((s) => (
127
+ <div key={s.title} className="gb-search-section" role="group" aria-label={s.title}>
128
+ <div className="gb-search-section-title">{s.title}</div>
129
+ {s.items.map((it) => {
130
+ n += 1;
131
+ const i = n;
132
+ return (
133
+ <a key={`${s.title}-${it.href}`} id={`${listId}-${i}`} href={it.href} role="option" aria-selected={i === active}
134
+ className={cx("gb-search-item", i === active && "is-active")} onMouseMove={() => setActive(i)}>
135
+ {renderItem ? renderItem(it) : (<><span className="gb-search-item-title">{it.title}</span>{it.meta ? <span className="gb-search-item-meta">{it.meta}</span> : null}</>)}
136
+ </a>
137
+ );
138
+ })}
139
+ </div>
140
+ ))}
141
+ </div>
142
+ <div className="gb-search-foot" aria-hidden="true"><span>↵ open</span><span>↑↓ move</span><span>esc</span></div>
143
+ </dialog>
144
+ </>
145
+ );
146
+ }
package/src/selection.tsx CHANGED
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * No "use client" of its own: only the two client modules import it (a server graph never reaches its hooks).
9
9
  */
10
+ import "./collections.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
10
11
  import React, { useCallback, useEffect, useRef, useState } from "react";
11
12
 
12
13
  export type BulkAction = { content: React.ReactNode; onAction: (ids: string[]) => void; tone?: "critical" };
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `themeScript` and `THEME_KEY` (@gemboss/ui 0.7.2; their own module since 0.23.0). The script goes in every root
3
+ * layout's <head>, so it must not bring `ThemeToggle`'s button styles (portal.css) to every route: it brings only the
4
+ * dark palette it switches on. theme.tsx says the rest.
5
+ */
6
+ import "./theme-dark.css"; // the palette `data-theme="dark"` turns on (0.23.0)
7
+
8
+ export const THEME_KEY = "gemboss-theme";
9
+
10
+ export const themeScript = `(function(){try{var t=localStorage.getItem("${THEME_KEY}");if(t!=="dark"&&t!=="light"){t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}document.documentElement.setAttribute("data-theme",t)}catch(e){}})();`;
package/src/theme.tsx CHANGED
@@ -10,15 +10,15 @@
10
10
  * every kit class (cards, tags, fields, buttons, the topbar) follows without a rule of its own.
11
11
  *
12
12
  * Opt-in: a surface without the toggle (the admin) never gets the attribute and stays light.
13
- * `themeScript` goes in <head> (`<script dangerouslySetInnerHTML={{ __html: themeScript }} />`): it applies
13
+ * `themeScript` (theme-script.ts since 0.23.0, so a root layout that only needs the script does not take portal.css
14
+ * with it) goes in <head> (`<script dangerouslySetInnerHTML={{ __html: themeScript }} />`): it applies
14
15
  * the stored choice, else the OS's, before the first paint, so a dark page never flashes white.
15
16
  */
17
+ import "./theme-dark.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
18
+ import "./portal.css"; // ThemeToggle's button (.gb-theme-toggle) is drawn there
16
19
  import React, { useEffect, useState } from "react";
17
20
  import { Icon } from "./icons";
18
-
19
- export const THEME_KEY = "gemboss-theme";
20
-
21
- export const themeScript = `(function(){try{var t=localStorage.getItem("${THEME_KEY}");if(t!=="dark"&&t!=="light"){t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}document.documentElement.setAttribute("data-theme",t)}catch(e){}})();`;
21
+ import { THEME_KEY } from "./theme-script";
22
22
 
23
23
  export function ThemeToggle({ labels = { toDark: "Switch to dark mode", toLight: "Switch to light mode" } }: {
24
24
  labels?: { toDark: string; toLight: string };