@gemboss/ui 0.13.0 → 0.25.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +512 -1
  2. package/README.md +426 -14
  3. package/catalog/editor.html +1027 -0
  4. package/catalog/index.html +1215 -90
  5. package/gemboss-ui.css +3 -4698
  6. package/package.json +24 -5
  7. package/parts/components.css +5 -0
  8. package/parts/core.css +5 -0
  9. package/parts/shell.css +5 -0
  10. package/src/app-shell.css +1 -256
  11. package/src/assistant/engine.tsx +607 -0
  12. package/src/assistant/host.tsx +1 -0
  13. package/src/assistant/index.ts +1 -0
  14. package/src/assistant-engine.css +1 -0
  15. package/src/assistant.css +1 -558
  16. package/src/auth.css +1 -606
  17. package/src/auth.tsx +1 -0
  18. package/src/board-move.ts +194 -0
  19. package/src/chat-composer.css +1 -110
  20. package/src/chat.css +1 -432
  21. package/src/collections.css +1 -350
  22. package/src/collections.tsx +170 -40
  23. package/src/dialog.tsx +3 -135
  24. package/src/editor/controls.tsx +207 -0
  25. package/src/editor/css-url.ts +11 -0
  26. package/src/editor/frame.tsx +243 -0
  27. package/src/editor/index.ts +52 -0
  28. package/src/editor/layers.tsx +136 -0
  29. package/src/editor/panel.tsx +189 -0
  30. package/src/editor/panes.tsx +45 -0
  31. package/src/editor/sortable.tsx +79 -0
  32. package/src/editor/swatch.ts +45 -0
  33. package/src/editor/tree.tsx +61 -0
  34. package/src/editor-layers-plain.js +1 -0
  35. package/src/editor-plain.js +1 -0
  36. package/src/editor.css +1 -1099
  37. package/src/gemboss-field.css +1 -112
  38. package/src/gemboss-tokens.css +1 -164
  39. package/src/icons.tsx +2 -0
  40. package/src/index-table.tsx +20 -56
  41. package/src/index.ts +16 -6
  42. package/src/kit-plain.css +1 -499
  43. package/src/kit-plain.js +1 -575
  44. package/src/launcher.tsx +1 -0
  45. package/src/menu-layer.ts +94 -0
  46. package/src/menu.tsx +28 -2
  47. package/src/portal.css +1 -139
  48. package/src/portal.tsx +1 -0
  49. package/src/reactions-data.ts +59 -0
  50. package/src/reactions.css +1 -0
  51. package/src/reactions.tsx +140 -0
  52. package/src/resource-list-select.tsx +73 -0
  53. package/src/search-dialog.tsx +146 -0
  54. package/src/segmented-click.ts +20 -0
  55. package/src/segmented.tsx +98 -0
  56. package/src/selection.tsx +79 -0
  57. package/src/shell.css +1 -1079
  58. package/src/theme-dark.css +1 -66
  59. package/src/theme-script.ts +10 -0
  60. package/src/theme.tsx +5 -5
  61. package/src/ui.css +1 -610
@@ -0,0 +1,207 @@
1
+ "use client";
2
+ // The settings panel's CONTROLS (0.17.0, step 3 of 5 of the editor kit): segmented, colour, slider, the four-edge px box
3
+ // and the image slot. Each takes a value and gives one back through `onChange`; nothing here knows what is edited, what
4
+ // schema a value came from or where it is saved. Wrap each in `Field` (panel.tsx) for its label and hint.
5
+ //
6
+ // Moved from the admin (apps/admin: FieldRenderer.tsx, element-inspector-controls.tsx) with the markup, the class names
7
+ // and the commit rules they had there, so the admin's editors draw and behave exactly as before:
8
+ // · text-like boxes keep a DRAFT and commit on blur, Enter blurs (a live preview is not hammered per keystroke);
9
+ // · the colour swatch commits on every change, the colour's text box on blur / Enter;
10
+ // · the slider commits on every change (its number);
11
+ // · the edge box hands each keystroke to the caller (`onChange(side, raw)`) and says when to commit (`onCommit`, on
12
+ // blur): the caller decides what an empty edge means, and the admin has three answers to that on purpose.
13
+ import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
14
+ import { useEffect, useState } from "react";
15
+ import type React from "react";
16
+ import { cssUrl } from "./css-url";
17
+ import { swatchState } from "./swatch";
18
+ import { Segmented } from "../segmented";
19
+
20
+ const blurOnEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
21
+ if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); }
22
+ };
23
+
24
+ // ── Seg ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
25
+
26
+ export interface SegOption<T extends string = string> {
27
+ value: T;
28
+ /** Default: the value itself. */
29
+ label?: React.ReactNode;
30
+ }
31
+
32
+ export interface SegProps<T extends string = string> {
33
+ options: ReadonlyArray<SegOption<T>>;
34
+ /** The pressed option. A value that matches none presses none. */
35
+ value?: string;
36
+ onChange: (value: T) => void;
37
+ disabled?: boolean;
38
+ /** The group's accessible name (`role="group"`): the field's label, said again for a screen reader. */
39
+ label?: string;
40
+ /** On the group (the admin narrows two-option rows with a max-width). */
41
+ style?: React.CSSProperties;
42
+ }
43
+
44
+ /**
45
+ * The settings panel's switch: the kit's `Segmented` stretched to its row (`.ui-seg--fill`), the same look as every
46
+ * other switch in the editors (0.20.0; it was a grey track with a raised white tab of its own). `gemboss-ei-seg` stays on
47
+ * the group as the hook editor-plain.js finds, and each button carries its `data-value`.
48
+ */
49
+ export function Seg<T extends string = string>({ options, value, onChange, disabled, label, style }: SegProps<T>) {
50
+ return (
51
+ <Segmented<T> fill className="gemboss-ei-seg" style={style} ariaLabel={label ?? ""} value={(value ?? "") as T | ""} onChange={onChange} disabled={disabled}
52
+ options={options.map((o) => ({ value: o.value, label: o.label ?? o.value }))} />
53
+ );
54
+ }
55
+
56
+ // ── ColorControl ────────────────────────────────────────────────────────────────────────────────────────────────────
57
+
58
+ export interface ColorControlProps {
59
+ /** The text box's id (the Field's `htmlFor`). */
60
+ id?: string;
61
+ value: string;
62
+ /** A committed value: on every swatch change, and on the text box's blur / Enter. */
63
+ onChange: (value: string) => void;
64
+ disabled?: boolean;
65
+ /** The field's name; the swatch is announced as "<label> swatch". */
66
+ label: string;
67
+ placeholder?: string;
68
+ }
69
+
70
+ /**
71
+ * `.gemboss-ei-color`: a native swatch beside a text box. The swatch is DISABLED (with a title saying why) for anything
72
+ * but a 6-digit hex (`swatchState`): `<input type="color">` would show black for `#f00`, `rgba()`, `var()` or a gradient,
73
+ * and overwrite it with black on the first touch.
74
+ */
75
+ export function ColorControl({ id, value, onChange, disabled, label, placeholder }: ColorControlProps) {
76
+ const [draft, setDraft] = useState(value);
77
+ useEffect(() => { setDraft(value); }, [value]);
78
+ const { swatch, usable } = swatchState(draft);
79
+ return (
80
+ <div className="gemboss-ei-color">
81
+ <input type="color" className="gemboss-ei-swatch" aria-label={`${label} swatch`} value={swatch}
82
+ disabled={disabled || !usable}
83
+ title={usable ? undefined : `${draft} — not a plain 6-digit hex; edit it in the box below`}
84
+ onChange={(e) => { setDraft(e.target.value); onChange(e.target.value); }} />
85
+ <input id={id} className="gemboss-field gemboss-ei-inp" placeholder={placeholder ?? "#000000 / var(--…)"} value={draft} disabled={disabled}
86
+ onChange={(e) => setDraft(e.target.value)} onBlur={() => onChange(draft)} onKeyDown={blurOnEnter} />
87
+ </div>
88
+ );
89
+ }
90
+
91
+ // ── RangeControl ────────────────────────────────────────────────────────────────────────────────────────────────────
92
+
93
+ export interface RangeControlProps {
94
+ id?: string;
95
+ min: number;
96
+ max: number;
97
+ /** Default 1. */
98
+ step?: number;
99
+ value: number;
100
+ /** Every change (drag, ←/→, Home/End, PageUp/PageDown), as a number. */
101
+ onChange: (value: number) => void;
102
+ disabled?: boolean;
103
+ }
104
+
105
+ /** `input.gemboss-ei-range` (the native slider in the primary colour). Show its value with `Field`'s `labelValue`. */
106
+ export function RangeControl({ id, min, max, step, value, onChange, disabled }: RangeControlProps) {
107
+ return (
108
+ <input id={id} type="range" className="gemboss-ei-range" min={min} max={max} step={step ?? 1}
109
+ value={value} disabled={disabled} onChange={(e) => onChange(Number(e.target.value))} />
110
+ );
111
+ }
112
+
113
+ // ── EdgeBox ─────────────────────────────────────────────────────────────────────────────────────────────────────────
114
+
115
+ export type EdgeSide = "t" | "r" | "b" | "l";
116
+ const SIDES: readonly EdgeSide[] = ["t", "r", "b", "l"];
117
+ const SIDE_NAME: Record<EdgeSide, string> = { t: "top", r: "right", b: "bottom", l: "left" };
118
+
119
+ export interface EdgeBoxProps {
120
+ /** The field's name; each box is announced as "<label> top / right / bottom / left". */
121
+ label: string;
122
+ /** What each box shows. A number, the raw text being typed, or empty. */
123
+ values: Partial<Record<EdgeSide, string | number>>;
124
+ /** Per side; default "0". The admin shows the inherited desktop value here while editing the phone override. */
125
+ placeholders?: Partial<Record<EdgeSide, string>>;
126
+ /** Every keystroke in one box. */
127
+ onChange: (side: EdgeSide, raw: string) => void;
128
+ /** A box lost focus (Enter blurs): commit what the boxes hold. */
129
+ onCommit: () => void;
130
+ disabled?: boolean;
131
+ /** `gemboss-field--narrow` on each box (the field family's tight lane). Default true. */
132
+ narrow?: boolean;
133
+ /** Ids for the boxes, per side. */
134
+ ids?: Partial<Record<EdgeSide, string>>;
135
+ }
136
+
137
+ /** `.gemboss-ei-edgebox`: four px boxes, T · R · B · L, mono and centred. */
138
+ export function EdgeBox({ label, values, placeholders, onChange, onCommit, disabled, narrow = true, ids }: EdgeBoxProps) {
139
+ const cls = narrow ? "gemboss-field gemboss-field--narrow gemboss-ei-inp" : "gemboss-field gemboss-ei-inp";
140
+ return (
141
+ <div className="gemboss-ei-edgebox">
142
+ {SIDES.map((s) => (
143
+ <div className="gemboss-ei-edge" key={s}>
144
+ <input className={cls} inputMode="numeric" placeholder={placeholders?.[s] ?? "0"}
145
+ aria-label={`${label} ${SIDE_NAME[s]}`} id={ids?.[s]} value={values[s] ?? ""} disabled={disabled}
146
+ onChange={(e) => onChange(s, e.target.value)} onBlur={onCommit} onKeyDown={blurOnEnter} />
147
+ <span>{s.toUpperCase()}</span>
148
+ </div>
149
+ ))}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ // ── ImageControl ────────────────────────────────────────────────────────────────────────────────────────────────────
155
+
156
+ /** A dashed pill button (`.gemboss-ei-addchip`): an image slot's Library / Generate / Clear, a panel's "+ Add". */
157
+ export function ChipButton({ disabled, expanded, onClick, children }: {
158
+ disabled?: boolean;
159
+ /** `aria-expanded`, for a chip that opens something under it (the Library grid). */
160
+ expanded?: boolean;
161
+ onClick?: React.MouseEventHandler<HTMLButtonElement>;
162
+ children: React.ReactNode;
163
+ }) {
164
+ return <button type="button" className="gemboss-ei-addchip" disabled={disabled} aria-expanded={expanded} onClick={onClick}>{children}</button>;
165
+ }
166
+
167
+ export interface ImageControlProps {
168
+ /** The URL box's id. */
169
+ id?: string;
170
+ /** The committed URL: what the thumbnail shows. */
171
+ value: string;
172
+ /** A committed URL: on blur / Enter when the box differs from `value`, and "" from Clear. */
173
+ onChange: (url: string) => void;
174
+ disabled?: boolean;
175
+ placeholder?: string;
176
+ /**
177
+ * The URL box's text, when the caller holds it (to put a picked or generated URL in the box at once, before `value`
178
+ * comes back). Omitted, the control keeps its own draft and resets it whenever `value` changes.
179
+ */
180
+ draft?: string;
181
+ onDraftChange?: (text: string) => void;
182
+ /** Chips before Clear (`ChipButton`): Library, Generate. */
183
+ actions?: React.ReactNode;
184
+ /** Draw Clear while there is a value. Default true. */
185
+ clearable?: boolean;
186
+ }
187
+
188
+ /** `.gemboss-ei-img`: a 52px thumbnail (— when empty) beside the URL box and its chips. */
189
+ export function ImageControl({ id, value, onChange, disabled, placeholder, draft, onDraftChange, actions, clearable = true }: ImageControlProps) {
190
+ const [own, setOwn] = useState(value);
191
+ useEffect(() => { setOwn(value); }, [value]);
192
+ const text = draft ?? own;
193
+ const setText = (next: string) => { if (draft === undefined) setOwn(next); onDraftChange?.(next); };
194
+ return (
195
+ <div className="gemboss-ei-img">
196
+ <div className="gemboss-ei-img-thumb" style={value ? { backgroundImage: cssUrl(value) } : undefined} aria-hidden>{value ? null : "—"}</div>
197
+ <div className="gemboss-ei-img-ctl">
198
+ <input id={id} className="gemboss-field gemboss-ei-inp" placeholder={placeholder ?? "Paste image URL…"} value={text} disabled={disabled}
199
+ onChange={(e) => setText(e.target.value)} onBlur={() => { if (text !== value) onChange(text); }} onKeyDown={blurOnEnter} />
200
+ <div className="gemboss-ei-img-actions">
201
+ {actions}
202
+ {clearable && value ? <ChipButton disabled={disabled} onClick={() => { setText(""); onChange(""); }}>Clear</ChipButton> : null}
203
+ </div>
204
+ </div>
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,11 @@
1
+ // A CSS `url("…")` for any image URL (0.20.1). Plain TypeScript, no "use client": a server component may build a
2
+ // background with it (also `@gemboss/ui/editor/css-url`).
3
+ //
4
+ // Unquoted, a URL holding ")" or a space ends the token early, the declaration is dropped, and the thumbnail shows
5
+ // nothing. Quoted, only `"` and `\` need a backslash; a control character (tab, newline, …) is written as a hex escape
6
+ // followed by a space, the one form CSS reads back as that character. (JSON.stringify is NOT this: CSS reads its `\n`
7
+ // as "n" and its `\u0001` as the text "u0001".) editor-plain.js writes the same thing; editor-panel-browser.test.tsx
8
+ // holds the two to one answer.
9
+ export function cssUrl(url: string): string {
10
+ return `url("${url.replace(/["\\]/g, "\\$&").replace(/[\u0000-\u001f\u007f]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}")`;
11
+ }
@@ -0,0 +1,243 @@
1
+ // What EVERY editor draws: EditorFrame, EditorTopbar (+ EditorBack, EditorSaveState) and DeviceToggle, kept in this one
2
+ // module on purpose (the theme editor's own pane controls are panes.tsx; see index.ts). The device toggle is also drawn
3
+ // by the plugin editor and the studio canvas, so together they ride in the chunk the toggle already had in the admin.
4
+ // Split into one module per piece, the admin build grew a lazy chunk to place them (its runtime +52 bytes, every
5
+ // screen's first load +20 B brotli). Measured 06/10/2026.
6
+ import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
7
+ import type React from "react";
8
+
9
+ /** Which half a phone shows (≤760px shows one at a time; wider shows both). Values are the CSS's `data-mobile-pane`. */
10
+ export type EditorPane = "chat" | "preview";
11
+
12
+ /** The seam between the left column and the preview: its width is the frame's, the drag/keyboard logic is the caller's. */
13
+ export interface EditorSplit {
14
+ /** The left column's width in px. Written to `--gemboss-chat-w` on the body, and read out as the seam's `aria-valuenow`. */
15
+ width: number;
16
+ /** The seam's range for a screen reader. `max` should be the ceiling the box really allows, not a constant. */
17
+ min: number;
18
+ max: number;
19
+ /** The left column is hidden for a full-width preview: `--gemboss-chat-w` becomes 0 and the seam goes. */
20
+ collapsed?: boolean;
21
+ /** Start a drag (a POINTER event: touch is a pointer, a mouse event never fires for a finger). */
22
+ onPointerDown?: React.PointerEventHandler<HTMLDivElement>;
23
+ /** Arrow keys / Home / End: the seam is focusable, so a key must move it. */
24
+ onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
25
+ /** Accessible name of the seam. Default "Resize chat panel". */
26
+ label?: string;
27
+ }
28
+
29
+ export interface EditorFrameProps {
30
+ /** Added to `.gemboss-editor` (the admin's page and section editors add `gemboss-pageedit`). */
31
+ className?: string;
32
+ /** Mounted inside another surface instead of over the viewport (`.gemboss-editor--embedded`). */
33
+ embedded?: boolean;
34
+ /** Everything above the body, in order: the top bar, and any banner or notice that belongs under it. */
35
+ top?: React.ReactNode;
36
+ /** The left column. Its root is a `.gemboss-ec` (the chat / Configure column the frame sizes). */
37
+ left?: React.ReactNode;
38
+ /** The seam. Omit it for a fixed split. */
39
+ split?: EditorSplit;
40
+ /** The preview pane's content (the canvas and anything that floats over it). */
41
+ preview?: React.ReactNode;
42
+ /** The right column: a panel docked over the preview's right edge (`.gemboss-editor-dock`, 320px, ≤90%), e.g. the
43
+ * theme editor's Global styles. Rendered first inside the pane, so the canvas and its overlays stack above it in
44
+ * source order exactly as they did when each editor drew it inline. Nothing (null/false) draws nothing. */
45
+ right?: React.ReactNode;
46
+ /** Added to the dock (`.gemboss-editor-dock`). */
47
+ rightClassName?: string;
48
+ /** Phones: which pane shows (`data-mobile-pane`). Put an `EditorPaneTabs` first in `children` to let them switch. */
49
+ mobilePane?: EditorPane;
50
+ bodyRef?: React.Ref<HTMLDivElement>;
51
+ /** Added to `.gemboss-editor-body`. */
52
+ bodyClassName?: string;
53
+ previewRef?: React.Ref<HTMLDivElement>;
54
+ /** Added to `.gemboss-editor-preview-pane`. */
55
+ previewClassName?: string;
56
+ previewStyle?: React.CSSProperties;
57
+ /** After the body: the phone's pane switch, drawers, popups, anything laid over the whole editor. */
58
+ children?: React.ReactNode;
59
+ }
60
+
61
+ /**
62
+ * `div.gemboss-editor`: the editor, over the whole viewport (`position: fixed; inset: 0`) unless `embedded`.
63
+ * Markup, in order: `top` · `.gemboss-editor-body` (left · seam · preview pane, `right` docked first inside it) · `children`.
64
+ */
65
+ export function EditorFrame({
66
+ className, embedded, top, left, split, preview, right, rightClassName, mobilePane,
67
+ bodyRef, bodyClassName, previewRef, previewClassName, previewStyle, children,
68
+ }: EditorFrameProps) {
69
+ return (
70
+ <div className={["gemboss-editor", embedded && "gemboss-editor--embedded", className].filter(Boolean).join(" ")}>
71
+ {top}
72
+ <div className={["gemboss-editor-body", bodyClassName, split?.collapsed && "is-chat-collapsed"].filter(Boolean).join(" ")}
73
+ data-mobile-pane={mobilePane} ref={bodyRef}
74
+ style={split ? ({ "--gemboss-chat-w": `${split.collapsed ? 0 : split.width}px` } as React.CSSProperties) : undefined}>
75
+ {left}
76
+ {split ? (
77
+ <div className="gemboss-editor-resizer ui-resizer" onPointerDown={split.onPointerDown} onKeyDown={split.onKeyDown}
78
+ role="separator" aria-orientation="vertical" aria-label={split.label ?? "Resize chat panel"}
79
+ aria-valuenow={split.width} aria-valuemin={split.min} aria-valuemax={split.max} tabIndex={0} />
80
+ ) : null}
81
+ <div className={previewClassName ? `gemboss-editor-preview-pane ${previewClassName}` : "gemboss-editor-preview-pane"} ref={previewRef} style={previewStyle}>
82
+ {right != null && right !== false ? <div className={rightClassName ? `gemboss-editor-dock ${rightClassName}` : "gemboss-editor-dock"}>{right}</div> : null}
83
+ {preview}
84
+ </div>
85
+ </div>
86
+ {children}
87
+ </div>
88
+ );
89
+ }
90
+
91
+ // ── Top bar ────────────────────────────────────────────────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * The bar's layout, by what sits in it (the CSS is written per variant):
95
+ * theme → `--theme`: content-sized tracks above 1000px; the bar with a page picker and a Publish split button.
96
+ * page → `.gemboss-pageedit-top`: same tracks; a title, a type chip and a status beside ← (page and section editors).
97
+ * guest → `--guest`: 1fr auto 1fr; a brand, a note, sign-in (the try door, no store yet).
98
+ */
99
+ export type EditorTopbarVariant = "theme" | "page" | "guest";
100
+ const VARIANT: Record<EditorTopbarVariant, string> = {
101
+ theme: "gemboss-editor-topbar--theme",
102
+ page: "gemboss-pageedit-top",
103
+ guest: "gemboss-editor-topbar--guest",
104
+ };
105
+
106
+ export interface EditorTopbarProps {
107
+ variant?: EditorTopbarVariant;
108
+ className?: string;
109
+ /** The way out, first in the bar: an `EditorBack`. */
110
+ back?: React.ReactNode;
111
+ /** Show / hide the left column (the frame's `split.collapsed`): an `EditorChatToggle`. Drawn between ← and the title. */
112
+ chatToggle?: React.ReactNode;
113
+ /** What is being edited: "Theme editor", "Page editor"… (`.gemboss-editor-tb-name`). */
114
+ title?: React.ReactNode;
115
+ /** After the title, in the left cluster: the page's name, a type chip, the save state, a mode switch. */
116
+ left?: React.ReactNode;
117
+ /** The centre cluster: page picker, `DeviceToggle`. */
118
+ center?: React.ReactNode;
119
+ /** The right cluster: tools, the ⋯ menu, Publish. */
120
+ right?: React.ReactNode;
121
+ /** A transient line under the bar (publish progress), floating top-right; `state` is a class: done | error | running.
122
+ * Nothing is drawn while `message` is empty. */
123
+ status?: { state: string; message: React.ReactNode } | null;
124
+ }
125
+
126
+ /** `header.gemboss-editor-topbar`: three clusters (left · center · right), 52px. Returns the header and, when there is
127
+ * one, the status line after it. */
128
+ export function EditorTopbar({ variant = "page", className, back, chatToggle, title, left, center, right, status }: EditorTopbarProps) {
129
+ return (
130
+ <>
131
+ <header className={`gemboss-editor-topbar ${VARIANT[variant]}${className ? ` ${className}` : ""}`}>
132
+ <div className="gemboss-editor-tb-left">
133
+ {back}
134
+ {chatToggle}
135
+ {title != null && title !== false ? <span className="gemboss-editor-tb-name">{title}</span> : null}
136
+ {left}
137
+ </div>
138
+ <div className="gemboss-editor-tb-center">{center}</div>
139
+ <div className="gemboss-editor-tb-right">{right}</div>
140
+ </header>
141
+ {status && status.message != null && status.message !== false && status.message !== "" ? (
142
+ <div className={`gemboss-editor-statusbar ${status.state}`} role="status">{status.message}</div>
143
+ ) : null}
144
+ </>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * ← Back: `as={Link}` (plus `href`) for a router link, a `<button type="button">` with `onClick` by default.
150
+ * The arrow is the label's picture; the name a screen reader hears is `label` ("Back").
151
+ */
152
+ export function EditorBack({ as: Comp = "button", label = "Back", ...rest }:
153
+ { as?: React.ElementType; label?: string } & Omit<React.AllHTMLAttributes<HTMLElement>, "as" | "label" | "className" | "children">) {
154
+ return <Comp {...(Comp === "button" ? { type: "button" } : null)} {...rest} className="gemboss-editor-back" aria-label={label}>←</Comp>;
155
+ }
156
+
157
+ /**
158
+ * The auto-save pill: "Saving…" with a spinner while an edit is in flight, "✓ Draft saved" once it lands.
159
+ * `idle` draws nothing unless `idleText` says what autosave means here (the section editor's "Autosaves", dimmed).
160
+ */
161
+ export function EditorSaveState({
162
+ state, savedText = "✓ Draft saved", savingText = "Saving…", savingTitle = "Saving draft…",
163
+ savedTitle = "Every change is saved to the draft automatically", idleText,
164
+ }: {
165
+ state: "idle" | "saving" | "saved";
166
+ savedText?: React.ReactNode; savingText?: React.ReactNode; savingTitle?: string; savedTitle?: string; idleText?: React.ReactNode;
167
+ }) {
168
+ if (state === "idle") {
169
+ return idleText != null && idleText !== false ? <span className="gemboss-editor-savestate" title={savedTitle} style={{ opacity: 0.55 }}>{idleText}</span> : null;
170
+ }
171
+ return (
172
+ <span className={`gemboss-editor-savestate is-${state}`} role="status" aria-live="polite" title={state === "saving" ? savingTitle : savedTitle}>
173
+ {state === "saving" ? <><span className="gemboss-narr-spin" aria-hidden />{savingText}</> : savedText}
174
+ </span>
175
+ );
176
+ }
177
+
178
+ // ── Device toggle: moved from the admin's components/shell/DeviceToggle.tsx (0.14.0), unchanged. The theme, page and
179
+ // section editors' device control, the plugin editor's, and the studio canvas's glyphs.
180
+
181
+ /** Desktop / tablet / mobile — the breakpoints the responsive style overrides target. */
182
+ export type PreviewBreakpoint = "desktop" | "tablet" | "mobile";
183
+
184
+ /** The monitor and the phone, ONE drawing each for every device control (the editors' toggle, a canvas's Desktop ·
185
+ * Mobile · Both). currentColor, so a pressed button's colour carries. */
186
+ export function MonitorGlyph({ size = 15 }: { size?: number }) {
187
+ return (
188
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
189
+ <rect x="2.5" y="4" width="19" height="12" rx="2" /><line x1="9" y1="20.5" x2="15" y2="20.5" /><line x1="12" y1="16" x2="12" y2="20.5" />
190
+ </svg>
191
+ );
192
+ }
193
+ export function PhoneGlyph({ size = 15 }: { size?: number }) {
194
+ return (
195
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
196
+ <rect x="6.5" y="2.5" width="11" height="19" rx="2.5" /><line x1="10.5" y1="18.5" x2="13.5" y2="18.5" />
197
+ </svg>
198
+ );
199
+ }
200
+
201
+ /** Desktop / tablet / mobile preview toggle. The kit's segmented control (`.ui-seg`, 06/10/2026), the same look as every
202
+ * other switch in the editors; `.gemboss-pv-devices` / `.gemboss-pv-device` stay as the hooks editor-plain.js finds. */
203
+ export function DeviceToggle({ device, onChange }: { device: PreviewBreakpoint; onChange: (device: PreviewBreakpoint) => void }) {
204
+ return (
205
+ <div className="ui-seg gemboss-pv-devices" role="group" aria-label="Device">
206
+ <button
207
+ type="button"
208
+ className={`gemboss-pv-device${device === "desktop" ? " active" : ""}`}
209
+ aria-label="Desktop"
210
+ title="Desktop"
211
+ aria-pressed={device === "desktop"}
212
+ onClick={() => onChange("desktop")}
213
+ >
214
+ {/* Monitor SVG — the ▭/▯ box glyphs were indistinguishable and rendered as tofu on some fonts. */}
215
+ <MonitorGlyph />
216
+ </button>
217
+ <button
218
+ type="button"
219
+ className={`gemboss-pv-device${device === "tablet" ? " active" : ""}`}
220
+ aria-label="Tablet"
221
+ title="Tablet"
222
+ aria-pressed={device === "tablet"}
223
+ onClick={() => onChange("tablet")}
224
+ >
225
+ {/* Tablet SVG — a wider, landscape-ish slate to read distinctly from the phone. */}
226
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
227
+ <rect x="4" y="3" width="16" height="18" rx="2" /><line x1="10.5" y1="18" x2="13.5" y2="18" />
228
+ </svg>
229
+ </button>
230
+ <button
231
+ type="button"
232
+ className={`gemboss-pv-device${device === "mobile" ? " active" : ""}`}
233
+ aria-label="Mobile"
234
+ title="Mobile"
235
+ aria-pressed={device === "mobile"}
236
+ onClick={() => onChange("mobile")}
237
+ >
238
+ {/* Phone SVG */}
239
+ <PhoneGlyph />
240
+ </button>
241
+ </div>
242
+ );
243
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `@gemboss/ui/editor` (0.14.0, step 2 of 5 of the editor kit; the panel and its controls since 0.17.0, step 3): the
3
+ * EDITOR FRAME and its TOP BAR — the full-screen shell
4
+ * the admin's theme, page and section editors are drawn in, as React components. The look is `@gemboss/ui/editor.css`
5
+ * (step 1); this is the markup that look is written for, so a second editor (GemFactory's Builder) gets the same frame
6
+ * instead of a copy.
7
+ *
8
+ * ┌ EditorTopbar ──────────────────────────────────────────────────────────────────────────────────────┐
9
+ * │ ← [toggle] Title · left… center (page picker, DeviceToggle) right (tools, ⋯, Publish) │
10
+ * ├ EditorFrame body ──────────────────────────────────────────────────────────────────────────────────┤
11
+ * │ left (.gemboss-ec) ║ seam ║ preview pane (preview + `right` docked over its right edge) │
12
+ * └────────────────────────────────────────────────────────────────── EditorPaneTabs (phones only) ─────┘
13
+ *
14
+ * FRAME ONLY. Every component is stateless and knows nothing of what is edited: the chat, the Configure dock, the
15
+ * canvas, the page picker, the menus and Publish are the editor's own and arrive as slots. State that belongs to the
16
+ * frame (the seam's width, which pane a phone shows, whether the left column is collapsed) is passed IN; the admin keeps
17
+ * it in `useResizableChatPanel`, a page without React gets it from `gembossUI.initEditorFrame()` (editor-plain.js).
18
+ *
19
+ * Its OWN entry, not part of `@gemboss/ui`: like editor.css, only a screen that shows an editor pays for it. Two modules
20
+ * behind it, grouped by who draws what (measured in the admin build, 06/10/2026): `frame` holds what every editor draws
21
+ * (and the device toggle the plugin editor shares), `panes` the chat toggle and the phone pane switch only the theme
22
+ * editor draws. One module for all of it cost the page and section editors ~0.3 KB of code they never run; one module
23
+ * per piece made the admin build grow a lazy chunk, and with it every screen's first load. No "use client": nothing
24
+ * here holds state.
25
+ */
26
+ export {
27
+ DeviceToggle, EditorBack, EditorFrame, EditorSaveState, EditorTopbar, MonitorGlyph, PhoneGlyph,
28
+ type EditorFrameProps, type EditorPane, type EditorSplit, type EditorTopbarProps, type EditorTopbarVariant, type PreviewBreakpoint,
29
+ } from "./frame";
30
+ export { EditorChatToggle, EditorPaneTabs } from "./panes";
31
+ // 0.17.0 (step 3): the settings panel (tabs, header, collapsible group, field row) and its controls. Two more modules,
32
+ // by the same rule: `panel` is drawn by every editor's left column, `controls` only where values are edited. Both are
33
+ // "use client" (InspectorGroup may hold its own open state; the text-like controls hold a draft).
34
+ export {
35
+ Field, InspectorGroup, PanelAction, PanelHeader, PanelTabs,
36
+ type FieldProps, type InspectorGroupProps, type PanelHeaderProps, type PanelTab, type PanelTabsProps,
37
+ } from "./panel";
38
+ export {
39
+ ChipButton, ColorControl, EdgeBox, ImageControl, RangeControl, Seg,
40
+ type ColorControlProps, type EdgeBoxProps, type EdgeSide, type ImageControlProps, type RangeControlProps, type SegOption, type SegProps,
41
+ } from "./controls";
42
+ export { swatchState, type SwatchState } from "./swatch";
43
+ export { cssUrl } from "./css-url";
44
+ // Step 4 (0.22.0): the page's layers and the drawer (`layers`, stateless; the page editor draws them), a list whose rows
45
+ // reorder (`sortable`, "use client": a drag holds state; no admin screen uses it), and the element outline (`tree`, the
46
+ // inspector's). Same rule: grouped by who draws what.
47
+ export {
48
+ EditorDrawer, LayerList, layerName,
49
+ type EditorDrawerProps, type LayerItem, type LayerListProps,
50
+ } from "./layers";
51
+ export { SortableLayerList, type SortableLayerListProps } from "./sortable";
52
+ export { LayerTree, type LayerTreeProps, type LayerTreeRow } from "./tree";
@@ -0,0 +1,136 @@
1
+ // The editor's LAYERS and its DRAWER (step 4 of 5 of the editor kit): the page's sections as rows (LayerList) and the
2
+ // panel that slides over the editor from a side (EditorDrawer). Moved from the admin's page editor (Layers, Page settings,
3
+ // Edit content) with the markup and the classes they had, so those draw and behave exactly as before; the look is
4
+ // editor.css (`.gemboss-layer*`, `.gemboss-settings*`, `.gemboss-picker-*`). Stateless, so no "use client".
5
+ //
6
+ // Reordering by drag and Alt+↑ / Alt+↓ is SortableLayerList (sortable.tsx, its own module): a list that does not reorder
7
+ // (the admin's) does not carry that code. A section's elements as an indented outline (LayerTree) is tree.tsx.
8
+ import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
9
+ import type React from "react";
10
+
11
+ // ── LayerList ───────────────────────────────────────────────────────────────────────────────────────────────────────
12
+
13
+ export interface LayerItem {
14
+ id: string;
15
+ /** The row's first line (`.gemboss-layer-type`, 13px / 600, capitalised by CSS). */
16
+ title: React.ReactNode;
17
+ /** The second line, muted and cut with an ellipsis (`.gemboss-layer-head`). Drawn only when truthy. */
18
+ subtitle?: React.ReactNode;
19
+ /** The row's name for a screen reader where the title is not plain text ("Hide <name>", "Move <name>"). */
20
+ name?: string;
21
+ /** Nesting: each level indents the row by `indent` px. 0 / omitted draws no style. */
22
+ depth?: number;
23
+ /** Hidden on the page: the row is drawn faded (`.is-hidden`) and its eye button is pressed. */
24
+ hidden?: boolean;
25
+ }
26
+
27
+ export interface LayerListProps<T extends LayerItem = LayerItem> {
28
+ items: ReadonlyArray<T>;
29
+ /** The selected row (`.is-sel`, and its button says `aria-current`). */
30
+ selectedId?: string | null;
31
+ /** A click on a row's main button. */
32
+ onSelect?: (item: T, index: number) => void;
33
+ /** The row's own buttons, after the main one (`.gemboss-layer-acts`, 26px each). */
34
+ actions?: (item: T, index: number) => React.ReactNode;
35
+ /** Shown instead of the rows when there are none (`.gemboss-picker-empty`). */
36
+ empty?: React.ReactNode;
37
+ /** Hide / show, on when given: an eye button first among the row's actions (`aria-pressed` = hidden). */
38
+ onToggleHidden?: (item: T, hidden: boolean) => void;
39
+ /** Px per `depth` level. Default 14 (the element outline's step). */
40
+ indent?: number;
41
+ /** Extra props for a row's element and its main button, and what goes before the button: how SortableLayerList adds
42
+ * dragging and keys without a second copy of the markup. */
43
+ rowProps?: (item: T, index: number) => React.HTMLAttributes<HTMLDivElement> & { "data-drop"?: string; className?: string };
44
+ mainProps?: (item: T, index: number) => React.ButtonHTMLAttributes<HTMLButtonElement>;
45
+ lead?: (item: T, index: number) => React.ReactNode;
46
+ /** The list element's ref. */
47
+ listRef?: React.Ref<HTMLDivElement>;
48
+ }
49
+
50
+ export const layerName = (it: LayerItem) => it.name ?? (typeof it.title === "string" ? it.title : it.id);
51
+ const EYE = "M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z";
52
+
53
+ /** `.gemboss-layers-list` › one `.gemboss-layer` per item: the main button (title · subtitle), then the actions. */
54
+ export function LayerList<T extends LayerItem>({ items, selectedId, onSelect, actions, empty, onToggleHidden, indent = 14, rowProps, mainProps, lead, listRef }: LayerListProps<T>) {
55
+ return (
56
+ <div className="gemboss-layers-list" ref={listRef}>
57
+ {items.length === 0 ? (empty != null ? <div className="gemboss-picker-empty">{empty}</div> : null) : items.map((it, i) => {
58
+ const { className: extra, ...row } = rowProps?.(it, i) ?? {};
59
+ const cls = `gemboss-layer${selectedId === it.id ? " is-sel" : ""}${it.hidden ? " is-hidden" : ""}${extra ? ` ${extra}` : ""}`;
60
+ // The eye's name stays "Hide <name>" whichever way it is set: a toggle says its state with aria-pressed, and its
61
+ // tooltip says the same thing.
62
+ const eyeName = `Hide ${layerName(it)}`;
63
+ return (
64
+ <div key={it.id} className={cls} data-id={it.id} style={it.depth ? { paddingLeft: 6 + it.depth * indent } : undefined} {...row}>
65
+ {lead?.(it, i)}
66
+ <button type="button" className="gemboss-layer-main" aria-current={selectedId === it.id ? "true" : undefined}
67
+ onClick={onSelect ? () => onSelect(it, i) : undefined} {...mainProps?.(it, i)}>
68
+ <span className="gemboss-layer-type">{it.title}</span>
69
+ {it.subtitle ? <span className="gemboss-layer-head">{it.subtitle}</span> : null}
70
+ </button>
71
+ {actions || onToggleHidden ? (
72
+ <div className="gemboss-layer-acts">
73
+ {onToggleHidden ? (
74
+ <button type="button" className="gemboss-layer-eye" aria-pressed={!!it.hidden} aria-label={eyeName}
75
+ title={eyeName} onClick={() => onToggleHidden(it, !it.hidden)}>
76
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden>
77
+ <path d={EYE} /><circle cx="8" cy="8" r="2" />{it.hidden ? <path d="M2.5 13.5 13.5 2.5" data-gb-slash="" /> : null}
78
+ </svg>
79
+ </button>
80
+ ) : null}
81
+ {actions?.(it, i)}
82
+ </div>
83
+ ) : null}
84
+ </div>
85
+ );
86
+ })}
87
+ </div>
88
+ );
89
+ }
90
+
91
+ // ── EditorDrawer ────────────────────────────────────────────────────────────────────────────────────────────────────
92
+
93
+ export interface EditorDrawerProps {
94
+ /**
95
+ * `right` (default): `aside.gemboss-settings`, min(380px, 100%) wide (Page settings, Edit content). `left`:
96
+ * `aside.gemboss-layers`, 300px (Layers). Both full height, over a backdrop that closes them.
97
+ */
98
+ side?: "left" | "right";
99
+ /** The head's text (`.gemboss-picker-head`, 15px / 600) beside ✕. */
100
+ title: React.ReactNode;
101
+ onClose: () => void;
102
+ /** ✕'s accessible name. Default "Close". */
103
+ closeLabel?: string;
104
+ /** The drawer's accessible name. */
105
+ label?: string;
106
+ /** Added to the aside's class (the admin's `gemboss-page-section-content`). */
107
+ className?: string;
108
+ /** Above the head (the admin's "a newer revision exists" notice). */
109
+ before?: React.ReactNode;
110
+ /** The drawer's buttons, right-aligned at the bottom (`.gemboss-settings-foot`). */
111
+ footer?: React.ReactNode;
112
+ /** The body: a `.gemboss-settings-body` of `.gemboss-settings-field`s, a LayerList, … */
113
+ children?: React.ReactNode;
114
+ }
115
+
116
+ /**
117
+ * The panel that slides over the editor from a side, with a backdrop; ✕ and a click on the backdrop call `onClose`.
118
+ * Mount it while open (the caller holds whether it is): nothing is drawn while closed.
119
+ */
120
+ export function EditorDrawer({ side = "right", title, onClose, closeLabel = "Close", label, className, before, footer, children }: EditorDrawerProps) {
121
+ const base = side === "left" ? "gemboss-layers" : "gemboss-settings";
122
+ return (
123
+ <>
124
+ <div className="gemboss-picker-backdrop" onClick={onClose} />
125
+ <aside className={className ? `${base} ${className}` : base} aria-label={label}>
126
+ {before}
127
+ <div className="gemboss-picker-head">
128
+ <span>{title}</span>
129
+ <button type="button" onClick={onClose} aria-label={closeLabel}>✕</button>
130
+ </div>
131
+ {children}
132
+ {footer ? <div className="gemboss-settings-foot">{footer}</div> : null}
133
+ </aside>
134
+ </>
135
+ );
136
+ }