@gemboss/ui 0.13.0 → 0.21.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,242 @@
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 type React from "react";
7
+
8
+ /** Which half a phone shows (≤760px shows one at a time; wider shows both). Values are the CSS's `data-mobile-pane`. */
9
+ export type EditorPane = "chat" | "preview";
10
+
11
+ /** The seam between the left column and the preview: its width is the frame's, the drag/keyboard logic is the caller's. */
12
+ export interface EditorSplit {
13
+ /** The left column's width in px. Written to `--gemboss-chat-w` on the body, and read out as the seam's `aria-valuenow`. */
14
+ width: number;
15
+ /** The seam's range for a screen reader. `max` should be the ceiling the box really allows, not a constant. */
16
+ min: number;
17
+ max: number;
18
+ /** The left column is hidden for a full-width preview: `--gemboss-chat-w` becomes 0 and the seam goes. */
19
+ collapsed?: boolean;
20
+ /** Start a drag (a POINTER event: touch is a pointer, a mouse event never fires for a finger). */
21
+ onPointerDown?: React.PointerEventHandler<HTMLDivElement>;
22
+ /** Arrow keys / Home / End: the seam is focusable, so a key must move it. */
23
+ onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
24
+ /** Accessible name of the seam. Default "Resize chat panel". */
25
+ label?: string;
26
+ }
27
+
28
+ export interface EditorFrameProps {
29
+ /** Added to `.gemboss-editor` (the admin's page and section editors add `gemboss-pageedit`). */
30
+ className?: string;
31
+ /** Mounted inside another surface instead of over the viewport (`.gemboss-editor--embedded`). */
32
+ embedded?: boolean;
33
+ /** Everything above the body, in order: the top bar, and any banner or notice that belongs under it. */
34
+ top?: React.ReactNode;
35
+ /** The left column. Its root is a `.gemboss-ec` (the chat / Configure column the frame sizes). */
36
+ left?: React.ReactNode;
37
+ /** The seam. Omit it for a fixed split. */
38
+ split?: EditorSplit;
39
+ /** The preview pane's content (the canvas and anything that floats over it). */
40
+ preview?: React.ReactNode;
41
+ /** The right column: a panel docked over the preview's right edge (`.gemboss-editor-dock`, 320px, ≤90%), e.g. the
42
+ * theme editor's Global styles. Rendered first inside the pane, so the canvas and its overlays stack above it in
43
+ * source order exactly as they did when each editor drew it inline. Nothing (null/false) draws nothing. */
44
+ right?: React.ReactNode;
45
+ /** Added to the dock (`.gemboss-editor-dock`). */
46
+ rightClassName?: string;
47
+ /** Phones: which pane shows (`data-mobile-pane`). Put an `EditorPaneTabs` first in `children` to let them switch. */
48
+ mobilePane?: EditorPane;
49
+ bodyRef?: React.Ref<HTMLDivElement>;
50
+ /** Added to `.gemboss-editor-body`. */
51
+ bodyClassName?: string;
52
+ previewRef?: React.Ref<HTMLDivElement>;
53
+ /** Added to `.gemboss-editor-preview-pane`. */
54
+ previewClassName?: string;
55
+ previewStyle?: React.CSSProperties;
56
+ /** After the body: the phone's pane switch, drawers, popups, anything laid over the whole editor. */
57
+ children?: React.ReactNode;
58
+ }
59
+
60
+ /**
61
+ * `div.gemboss-editor`: the editor, over the whole viewport (`position: fixed; inset: 0`) unless `embedded`.
62
+ * Markup, in order: `top` · `.gemboss-editor-body` (left · seam · preview pane, `right` docked first inside it) · `children`.
63
+ */
64
+ export function EditorFrame({
65
+ className, embedded, top, left, split, preview, right, rightClassName, mobilePane,
66
+ bodyRef, bodyClassName, previewRef, previewClassName, previewStyle, children,
67
+ }: EditorFrameProps) {
68
+ return (
69
+ <div className={["gemboss-editor", embedded && "gemboss-editor--embedded", className].filter(Boolean).join(" ")}>
70
+ {top}
71
+ <div className={["gemboss-editor-body", bodyClassName, split?.collapsed && "is-chat-collapsed"].filter(Boolean).join(" ")}
72
+ data-mobile-pane={mobilePane} ref={bodyRef}
73
+ style={split ? ({ "--gemboss-chat-w": `${split.collapsed ? 0 : split.width}px` } as React.CSSProperties) : undefined}>
74
+ {left}
75
+ {split ? (
76
+ <div className="gemboss-editor-resizer ui-resizer" onPointerDown={split.onPointerDown} onKeyDown={split.onKeyDown}
77
+ role="separator" aria-orientation="vertical" aria-label={split.label ?? "Resize chat panel"}
78
+ aria-valuenow={split.width} aria-valuemin={split.min} aria-valuemax={split.max} tabIndex={0} />
79
+ ) : null}
80
+ <div className={previewClassName ? `gemboss-editor-preview-pane ${previewClassName}` : "gemboss-editor-preview-pane"} ref={previewRef} style={previewStyle}>
81
+ {right != null && right !== false ? <div className={rightClassName ? `gemboss-editor-dock ${rightClassName}` : "gemboss-editor-dock"}>{right}</div> : null}
82
+ {preview}
83
+ </div>
84
+ </div>
85
+ {children}
86
+ </div>
87
+ );
88
+ }
89
+
90
+ // ── Top bar ────────────────────────────────────────────────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * The bar's layout, by what sits in it (the CSS is written per variant):
94
+ * theme → `--theme`: content-sized tracks above 1000px; the bar with a page picker and a Publish split button.
95
+ * page → `.gemboss-pageedit-top`: same tracks; a title, a type chip and a status beside ← (page and section editors).
96
+ * guest → `--guest`: 1fr auto 1fr; a brand, a note, sign-in (the try door, no store yet).
97
+ */
98
+ export type EditorTopbarVariant = "theme" | "page" | "guest";
99
+ const VARIANT: Record<EditorTopbarVariant, string> = {
100
+ theme: "gemboss-editor-topbar--theme",
101
+ page: "gemboss-pageedit-top",
102
+ guest: "gemboss-editor-topbar--guest",
103
+ };
104
+
105
+ export interface EditorTopbarProps {
106
+ variant?: EditorTopbarVariant;
107
+ className?: string;
108
+ /** The way out, first in the bar: an `EditorBack`. */
109
+ back?: React.ReactNode;
110
+ /** Show / hide the left column (the frame's `split.collapsed`): an `EditorChatToggle`. Drawn between ← and the title. */
111
+ chatToggle?: React.ReactNode;
112
+ /** What is being edited: "Theme editor", "Page editor"… (`.gemboss-editor-tb-name`). */
113
+ title?: React.ReactNode;
114
+ /** After the title, in the left cluster: the page's name, a type chip, the save state, a mode switch. */
115
+ left?: React.ReactNode;
116
+ /** The centre cluster: page picker, `DeviceToggle`. */
117
+ center?: React.ReactNode;
118
+ /** The right cluster: tools, the ⋯ menu, Publish. */
119
+ right?: React.ReactNode;
120
+ /** A transient line under the bar (publish progress), floating top-right; `state` is a class: done | error | running.
121
+ * Nothing is drawn while `message` is empty. */
122
+ status?: { state: string; message: React.ReactNode } | null;
123
+ }
124
+
125
+ /** `header.gemboss-editor-topbar`: three clusters (left · center · right), 52px. Returns the header and, when there is
126
+ * one, the status line after it. */
127
+ export function EditorTopbar({ variant = "page", className, back, chatToggle, title, left, center, right, status }: EditorTopbarProps) {
128
+ return (
129
+ <>
130
+ <header className={`gemboss-editor-topbar ${VARIANT[variant]}${className ? ` ${className}` : ""}`}>
131
+ <div className="gemboss-editor-tb-left">
132
+ {back}
133
+ {chatToggle}
134
+ {title != null && title !== false ? <span className="gemboss-editor-tb-name">{title}</span> : null}
135
+ {left}
136
+ </div>
137
+ <div className="gemboss-editor-tb-center">{center}</div>
138
+ <div className="gemboss-editor-tb-right">{right}</div>
139
+ </header>
140
+ {status && status.message != null && status.message !== false && status.message !== "" ? (
141
+ <div className={`gemboss-editor-statusbar ${status.state}`} role="status">{status.message}</div>
142
+ ) : null}
143
+ </>
144
+ );
145
+ }
146
+
147
+ /**
148
+ * ← Back: `as={Link}` (plus `href`) for a router link, a `<button type="button">` with `onClick` by default.
149
+ * The arrow is the label's picture; the name a screen reader hears is `label` ("Back").
150
+ */
151
+ export function EditorBack({ as: Comp = "button", label = "Back", ...rest }:
152
+ { as?: React.ElementType; label?: string } & Omit<React.AllHTMLAttributes<HTMLElement>, "as" | "label" | "className" | "children">) {
153
+ return <Comp {...(Comp === "button" ? { type: "button" } : null)} {...rest} className="gemboss-editor-back" aria-label={label}>←</Comp>;
154
+ }
155
+
156
+ /**
157
+ * The auto-save pill: "Saving…" with a spinner while an edit is in flight, "✓ Draft saved" once it lands.
158
+ * `idle` draws nothing unless `idleText` says what autosave means here (the section editor's "Autosaves", dimmed).
159
+ */
160
+ export function EditorSaveState({
161
+ state, savedText = "✓ Draft saved", savingText = "Saving…", savingTitle = "Saving draft…",
162
+ savedTitle = "Every change is saved to the draft automatically", idleText,
163
+ }: {
164
+ state: "idle" | "saving" | "saved";
165
+ savedText?: React.ReactNode; savingText?: React.ReactNode; savingTitle?: string; savedTitle?: string; idleText?: React.ReactNode;
166
+ }) {
167
+ if (state === "idle") {
168
+ return idleText != null && idleText !== false ? <span className="gemboss-editor-savestate" title={savedTitle} style={{ opacity: 0.55 }}>{idleText}</span> : null;
169
+ }
170
+ return (
171
+ <span className={`gemboss-editor-savestate is-${state}`} role="status" aria-live="polite" title={state === "saving" ? savingTitle : savedTitle}>
172
+ {state === "saving" ? <><span className="gemboss-narr-spin" aria-hidden />{savingText}</> : savedText}
173
+ </span>
174
+ );
175
+ }
176
+
177
+ // ── Device toggle: moved from the admin's components/shell/DeviceToggle.tsx (0.14.0), unchanged. The theme, page and
178
+ // section editors' device control, the plugin editor's, and the studio canvas's glyphs.
179
+
180
+ /** Desktop / tablet / mobile — the breakpoints the responsive style overrides target. */
181
+ export type PreviewBreakpoint = "desktop" | "tablet" | "mobile";
182
+
183
+ /** The monitor and the phone, ONE drawing each for every device control (the editors' toggle, a canvas's Desktop ·
184
+ * Mobile · Both). currentColor, so a pressed button's colour carries. */
185
+ export function MonitorGlyph({ size = 15 }: { size?: number }) {
186
+ return (
187
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
188
+ <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" />
189
+ </svg>
190
+ );
191
+ }
192
+ export function PhoneGlyph({ size = 15 }: { size?: number }) {
193
+ return (
194
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
195
+ <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" />
196
+ </svg>
197
+ );
198
+ }
199
+
200
+ /** Desktop / tablet / mobile preview toggle. The kit's segmented control (`.ui-seg`, 06/10/2026), the same look as every
201
+ * other switch in the editors; `.gemboss-pv-devices` / `.gemboss-pv-device` stay as the hooks editor-plain.js finds. */
202
+ export function DeviceToggle({ device, onChange }: { device: PreviewBreakpoint; onChange: (device: PreviewBreakpoint) => void }) {
203
+ return (
204
+ <div className="ui-seg gemboss-pv-devices" role="group" aria-label="Device">
205
+ <button
206
+ type="button"
207
+ className={`gemboss-pv-device${device === "desktop" ? " active" : ""}`}
208
+ aria-label="Desktop"
209
+ title="Desktop"
210
+ aria-pressed={device === "desktop"}
211
+ onClick={() => onChange("desktop")}
212
+ >
213
+ {/* Monitor SVG — the ▭/▯ box glyphs were indistinguishable and rendered as tofu on some fonts. */}
214
+ <MonitorGlyph />
215
+ </button>
216
+ <button
217
+ type="button"
218
+ className={`gemboss-pv-device${device === "tablet" ? " active" : ""}`}
219
+ aria-label="Tablet"
220
+ title="Tablet"
221
+ aria-pressed={device === "tablet"}
222
+ onClick={() => onChange("tablet")}
223
+ >
224
+ {/* Tablet SVG — a wider, landscape-ish slate to read distinctly from the phone. */}
225
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
226
+ <rect x="4" y="3" width="16" height="18" rx="2" /><line x1="10.5" y1="18" x2="13.5" y2="18" />
227
+ </svg>
228
+ </button>
229
+ <button
230
+ type="button"
231
+ className={`gemboss-pv-device${device === "mobile" ? " active" : ""}`}
232
+ aria-label="Mobile"
233
+ title="Mobile"
234
+ aria-pressed={device === "mobile"}
235
+ onClick={() => onChange("mobile")}
236
+ >
237
+ {/* Phone SVG */}
238
+ <PhoneGlyph />
239
+ </button>
240
+ </div>
241
+ );
242
+ }
@@ -0,0 +1,43 @@
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";
@@ -0,0 +1,188 @@
1
+ "use client";
2
+ // The settings PANEL's pieces (0.17.0, step 3 of 5 of the editor kit): the tab rows, the panel header, the collapsible
3
+ // group and the field row. The admin draws its Configure column, its element inspector and its schema form with these;
4
+ // the look is editor.css (`.gemboss-ec-tabs`, `.gemboss-ei-*`), unchanged by the move. Stateless except
5
+ // InspectorGroup's own open/closed when the caller does not hold it. Controls (Seg, Color, Range, EdgeBox, Image) are
6
+ // controls.tsx: their own module, because only a screen that edits values draws them.
7
+ import { useState } from "react";
8
+ import type React from "react";
9
+
10
+ // ── PanelTabs ───────────────────────────────────────────────────────────────────────────────────────────────────────
11
+
12
+ export interface PanelTab<T extends string = string> {
13
+ id: T;
14
+ label: React.ReactNode;
15
+ /** Drawn before the label (the column tabs' 14px line icons). */
16
+ icon?: React.ReactNode;
17
+ /** The id of the panel this tab shows: `aria-controls` (and editor-plain.js shows it, hiding its siblings'). */
18
+ controls?: string;
19
+ }
20
+
21
+ export interface PanelTabsProps<T extends string = string> {
22
+ tabs: ReadonlyArray<PanelTab<T>>;
23
+ value: T;
24
+ onChange: (id: T) => void;
25
+ /**
26
+ * `column`: the left column's row (`.gemboss-ec-tabs`, Configure · Chat; a selected tab has a 2px primary underline).
27
+ * `inspector`: the settings panel's segmented row (`.gemboss-ei-tabs`, Content · Design · Advanced · Elements).
28
+ */
29
+ variant?: "column" | "inspector";
30
+ /** The tablist's accessible name. */
31
+ label?: string;
32
+ /**
33
+ * Roving focus (default on): only the selected tab is in the Tab order, ←/→ move the selection and the focus with
34
+ * it, wrapping at the ends. Off, every tab is a plain stop and arrows do nothing (the admin's Configure · Chat row,
35
+ * kept exactly as it was in step 3).
36
+ */
37
+ roving?: boolean;
38
+ }
39
+
40
+ /** A row of `role="tab"` buttons. The caller owns the selection and draws the panel under it. */
41
+ export function PanelTabs<T extends string = string>({ tabs, value, onChange, variant = "column", label, roving = true }: PanelTabsProps<T>) {
42
+ const inspector = variant === "inspector";
43
+ const onKeyDown = roving ? (e: React.KeyboardEvent<HTMLDivElement>) => {
44
+ if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
45
+ e.preventDefault();
46
+ const i = tabs.findIndex((t) => t.id === value);
47
+ const ni = e.key === "ArrowRight" ? (i + 1) % tabs.length : (i - 1 + tabs.length) % tabs.length;
48
+ onChange(tabs[ni].id);
49
+ // Focus follows the selection (roving tabindex).
50
+ (e.currentTarget.children[ni] as HTMLButtonElement | undefined)?.focus();
51
+ } : undefined;
52
+ return (
53
+ <div className={inspector ? "gemboss-ei-tabs" : "gemboss-ec-tabs"} role="tablist" aria-label={label} onKeyDown={onKeyDown}>
54
+ {tabs.map((t) => {
55
+ const on = t.id === value;
56
+ return (
57
+ // data-value: what a page without React reads back (editor-plain.js), since the label can be anything.
58
+ <button key={t.id} type="button" role="tab" data-value={t.id}
59
+ className={inspector ? `gemboss-ei-tab${on ? " on" : ""}` : "gemboss-ec-tab"}
60
+ aria-selected={on} aria-controls={t.controls} tabIndex={roving ? (on ? 0 : -1) : undefined} onClick={() => onChange(t.id)}>
61
+ {t.icon}{t.label}
62
+ </button>
63
+ );
64
+ })}
65
+ </div>
66
+ );
67
+ }
68
+
69
+ // ── PanelHeader ─────────────────────────────────────────────────────────────────────────────────────────────────────
70
+
71
+ export interface PanelHeaderProps {
72
+ /** What the panel edits (a section's name). */
73
+ title: React.ReactNode;
74
+ /** The line under it, monospaced and muted (the section's type). Drawn whenever it is not undefined. */
75
+ subtitle?: React.ReactNode;
76
+ /** Icon buttons after the title: `PanelAction`s. The first sits at the right edge's start (margin-left: auto). */
77
+ actions?: React.ReactNode;
78
+ /** Draws ✕ last. Omit it for a docked panel (nothing to close). */
79
+ onClose?: () => void;
80
+ /** The close button's accessible name. Default "Close". */
81
+ closeLabel?: string;
82
+ /** A floating panel's grab handle: adds `.gemboss-ei-drag` (grab cursor) and takes the mouse-down that starts a drag. */
83
+ onDragStart?: React.MouseEventHandler<HTMLElement>;
84
+ }
85
+
86
+ /** `header.gemboss-ei-head`: title (+ subtitle) · actions · ✕. */
87
+ export function PanelHeader({ title, subtitle, actions, onClose, closeLabel = "Close", onDragStart }: PanelHeaderProps) {
88
+ return (
89
+ <header className={onDragStart ? "gemboss-ei-head gemboss-ei-drag" : "gemboss-ei-head"} onMouseDown={onDragStart}>
90
+ <span className="gemboss-ei-title">
91
+ {title}
92
+ {subtitle !== undefined ? <small>{subtitle}</small> : null}
93
+ </span>
94
+ {actions}
95
+ {onClose ? <button type="button" className="gemboss-ei-x" title="Close" aria-label={closeLabel} onClick={onClose}>✕</button> : null}
96
+ </header>
97
+ );
98
+ }
99
+
100
+ /** A 26px icon button for `PanelHeader`'s actions (`.gemboss-ei-act`). `label` is both its name and its tooltip unless `title` differs. */
101
+ export function PanelAction({ label, title, disabled, onClick, children }: {
102
+ label: string; title?: string; disabled?: boolean; onClick?: React.MouseEventHandler<HTMLButtonElement>; children: React.ReactNode;
103
+ }) {
104
+ return (
105
+ <button type="button" className="gemboss-ei-act" title={title ?? label} aria-label={label} disabled={disabled} onClick={onClick}>
106
+ {children}
107
+ </button>
108
+ );
109
+ }
110
+
111
+ // ── InspectorGroup ──────────────────────────────────────────────────────────────────────────────────────────────────
112
+
113
+ export interface InspectorGroupProps {
114
+ /** The header: 11px / 700, uppercase, muted (`.gemboss-ei-gh`). */
115
+ title?: React.ReactNode;
116
+ /** A short aside after the title, in the header's own line ("· all screens"). Collapsible groups only. */
117
+ note?: React.ReactNode;
118
+ /** Controlled: the caller holds whether the group is open (and keeps it across tabs, say). */
119
+ open?: boolean;
120
+ /** Uncontrolled: where it starts. Default open. */
121
+ defaultOpen?: boolean;
122
+ onOpenChange?: (open: boolean) => void;
123
+ /**
124
+ * Default true: the inspector's group, `.gemboss-ei-grp` with a header BUTTON (▾ / ▸, `aria-expanded`); closed, CSS
125
+ * hides everything after the header (`.gemboss-ei-gh[data-open="closed"] ~ *`), so the body stays mounted.
126
+ * `false`: the schema form's static group, `section.gemboss-ei-group` with an `h4.gemboss-ei-grouphead` (no toggle).
127
+ */
128
+ collapsible?: boolean;
129
+ /** A muted line after the body (`p.gemboss-ei-hint`). */
130
+ hint?: React.ReactNode;
131
+ children?: React.ReactNode;
132
+ }
133
+
134
+ export function InspectorGroup({ title, note, open, defaultOpen = true, onOpenChange, collapsible = true, hint, children }: InspectorGroupProps) {
135
+ const [own, setOwn] = useState(defaultOpen);
136
+ const hintNode = hint ? <p className="gemboss-ei-hint">{hint}</p> : null;
137
+ if (!collapsible) {
138
+ return (
139
+ <section className="gemboss-ei-group">
140
+ {title ? <h4 className="gemboss-ei-grouphead">{title}</h4> : null}
141
+ {children}
142
+ {hintNode}
143
+ </section>
144
+ );
145
+ }
146
+ const isOpen = open ?? own;
147
+ return (
148
+ <div className="gemboss-ei-grp">
149
+ <button type="button" className="gemboss-ei-gh" data-open={isOpen ? "open" : "closed"} aria-expanded={isOpen}
150
+ onClick={() => { if (open === undefined) setOwn(!isOpen); onOpenChange?.(!isOpen); }}>
151
+ <span className="gemboss-ei-chev" aria-hidden>{isOpen ? "▾" : "▸"}</span>{title}
152
+ {note ? <small className="gemboss-ei-gh-note">· {note}</small> : null}
153
+ </button>
154
+ {children}
155
+ {hintNode}
156
+ </div>
157
+ );
158
+ }
159
+
160
+ // ── Field ───────────────────────────────────────────────────────────────────────────────────────────────────────────
161
+
162
+ export interface FieldProps {
163
+ /** 13px / 500 above the control. `undefined` / `null` draws no label (a control named by its own aria-label). */
164
+ label?: React.ReactNode;
165
+ /** The control's id, so a click on the label focuses it. */
166
+ htmlFor?: string;
167
+ /** A value shown at the label's right edge, bold and tabular (a range's current value: `.gemboss-ei-rangeval`). */
168
+ labelValue?: React.ReactNode;
169
+ /** A muted line under the control (`p.gemboss-ei-hint`). Put it in `children` instead to place it elsewhere. */
170
+ hint?: React.ReactNode;
171
+ /** Added to `.gemboss-ei-fld` (the admin's `togrow`). */
172
+ className?: string;
173
+ style?: React.CSSProperties;
174
+ children?: React.ReactNode;
175
+ }
176
+
177
+ /** One labelled row of a panel: `div.gemboss-ei-fld` › label · control · hint. 12px under each row but the last. */
178
+ export function Field({ label, htmlFor, labelValue, hint, className, style, children }: FieldProps) {
179
+ return (
180
+ <div className={className ? `gemboss-ei-fld ${className}` : "gemboss-ei-fld"} style={style}>
181
+ {label != null ? (
182
+ <label htmlFor={htmlFor}>{label}{labelValue != null ? <span className="gemboss-ei-rangeval">{labelValue}</span> : null}</label>
183
+ ) : null}
184
+ {children}
185
+ {hint ? <p className="gemboss-ei-hint">{hint}</p> : null}
186
+ </div>
187
+ );
188
+ }
@@ -0,0 +1,44 @@
1
+ import type { EditorPane } from "./frame";
2
+
3
+ // The two pane controls only an editor with a collapsible left column and a phone pane switch draws (the admin's theme
4
+ // editor): their own module, so the page and section editors do not carry them.
5
+
6
+ /** The panel icon that shows or hides the left column (EditorTopbar's `chatToggle`): the left third is filled while the
7
+ * column is open, hollow and pressed when it is collapsed. */
8
+ export function EditorChatToggle({ collapsed, onToggle }: { collapsed?: boolean; onToggle: () => void }) {
9
+ return (
10
+ <button
11
+ className={`gemboss-editor-chat-toggle${collapsed ? " collapsed" : ""}`}
12
+ onClick={onToggle}
13
+ aria-pressed={collapsed}
14
+ title={collapsed ? "Show chat panel" : "Hide chat — full-width preview"}
15
+ aria-label={collapsed ? "Show chat panel" : "Hide chat panel"}
16
+ >
17
+ {/* A real SVG panel icon (geometrically centred): the ▭/◫ box glyphs' ink sits high in the em box. */}
18
+ <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true">
19
+ <rect x="1.75" y="2.75" width="12.5" height="10.5" rx="2" />
20
+ <line x1="6.25" y1="2.75" x2="6.25" y2="13.25" />
21
+ {!collapsed && <rect x="1.75" y="2.75" width="4.5" height="10.5" fill="currentColor" stroke="none" />}
22
+ </svg>
23
+ </button>
24
+ );
25
+ }
26
+
27
+ /**
28
+ * The phone's Chat | Preview switch (`.gemboss-editor-mobile-tabs`; CSS shows it ≤760px only, wider screens show both
29
+ * panes side by side). Put it FIRST in EditorFrame's `children` and give the frame the same `mobilePane`.
30
+ */
31
+ export function EditorPaneTabs({ value, onChange, labels }: {
32
+ value: EditorPane; onChange: (pane: EditorPane) => void; labels?: { chat?: string; preview?: string };
33
+ }) {
34
+ return (
35
+ <div className="gemboss-editor-mobile-tabs" role="tablist" aria-label="Editor panes">
36
+ <button type="button" role="tab" aria-selected={value === "chat"}
37
+ className={`gemboss-editor-mobile-tab${value === "chat" ? " active" : ""}`}
38
+ onClick={() => onChange("chat")}>{labels?.chat ?? "Chat"}</button>
39
+ <button type="button" role="tab" aria-selected={value === "preview"}
40
+ className={`gemboss-editor-mobile-tab${value === "preview" ? " active" : ""}`}
41
+ onClick={() => onChange("preview")}>{labels?.preview ?? "Preview"}</button>
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,45 @@
1
+ // What a colour swatch can and cannot show (moved from the admin's components/ui/color-field-value.ts in 0.17.0; the
2
+ // admin re-exports it from here, so the colour control and the admin's other colour boxes keep ONE answer).
3
+ //
4
+ // `<input type="color">` holds exactly one shape: a 7-character hex. Anything else it SANITISES to
5
+ // `#000000` — silently, with no error and no visual difference from a value someone actually chose.
6
+ //
7
+ // gemsite's `cssValue` decides whether a background paints, and it accepts five shapes. Measured:
8
+ //
9
+ // #ff0000 ✔ renders ✔ the swatch can show it
10
+ // #f00 ✔ renders ✘ shown as #000000 — even a 3-digit hex
11
+ // rgba(0,0,0,.5) ✔ renders ✘ shown as #000000
12
+ // var(--gem-color-accent) ✔ renders ✘ shown as #000000
13
+ // linear-gradient(90deg,#f00,#00f) ✔ renders ✘ shown as #000000
14
+ //
15
+ // So four of five were BOTH invisible and destructible: the panel showed black for a value that is
16
+ // not black, and touching the swatch overwrote it with the black it was pretending to show. A seller
17
+ // could lose a gradient by opening the panel to look at it.
18
+ //
19
+ // None of this is hypothetical. `gem_background` renders a gradient today, measured in chromium, and
20
+ // every one of these shapes is something the CLONE pipeline can read off a real storefront.
21
+ //
22
+ // Plain TypeScript, no JSX: the admin's node tests import it through the admin's re-export.
23
+
24
+ /** The only shape `<input type="color">` can hold without rewriting it. */
25
+ const SWATCHABLE = /^#[0-9a-fA-F]{6}$/;
26
+
27
+ export type SwatchState = {
28
+ /** What to put in the swatch. Never a colour the author did not choose. */
29
+ swatch: string;
30
+ /** False when the swatch would misrepresent the value — the text box is then the only truth. */
31
+ usable: boolean;
32
+ };
33
+
34
+ /**
35
+ * `whenEmpty` is the swatch shown for a BLANK value, and it is a parameter rather than a constant on
36
+ * purpose. Three surfaces call this now and they disagree: the element inspector and the schema form
37
+ * open an empty swatch on black, the page editor's "Background color" opens on white. That difference
38
+ * is harmless — an empty value misrepresents nothing, which is why `usable` stays true — and folding
39
+ * it into one answer would repaint a live control while fixing an unrelated bug. The lie is what gets
40
+ * consolidated here; the taste does not.
41
+ */
42
+ export function swatchState(value: string, whenEmpty = '#000000'): SwatchState {
43
+ if (!value) return { swatch: whenEmpty, usable: true };
44
+ return SWATCHABLE.test(value) ? { swatch: value, usable: true } : { swatch: '#000000', usable: false };
45
+ }
@@ -0,0 +1 @@
1
+ (function(S){"use strict";var b=S.gembossUI;if(!b){S.console&&S.console.error("@gemboss/ui editor-plain.js: load kit-plain.js first (it defines gembossUI)");return}if(b.initEditorFrame)return;var k=5,z=360,p=typeof WeakSet=="function"?new WeakSet:null,l=function(t,e,r){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:r}))},w=function(t,e){var r=Number(t);return isFinite(r)&&r>0?r:e};function c(t){return t.querySelector(".gemboss-editor-body")}function g(t){var e=c(t);return e&&e.querySelector(":scope > .gemboss-editor-resizer")}function v(t){var e=g(t);return{min:w(e&&e.getAttribute("aria-valuemin"),300),cap:w(e&&(e.getAttribute("data-gb-max")||e.getAttribute("aria-valuemax")),680)}}function x(t){var e=v(t),r=c(t),i=r?r.getBoundingClientRect().width:0;return i>0?Math.max(e.min,Math.min(e.cap,i-z-k)):e.cap}var h=typeof WeakMap=="function"?new WeakMap:null;function q(t){var e=h?h.get(t):t._gbWant;return e??y(t)}function C(t,e){h?h.set(t,e):t._gbWant=e}function d(t,e,r,i){var n=c(t),a=g(t);if(n){var s=i??x(t),f=v(t),o=Math.round(Math.min(s,Math.max(f.min,e)));if(n.style.setProperty("--gemboss-chat-w",o+"px"),a&&(a.setAttribute("aria-valuenow",String(o)),a.setAttribute("aria-valuemax",String(s))),r){C(t,o);var E=n.getAttribute("data-gb-storage");if(E)try{localStorage.setItem(E,String(o))}catch{}l(t,"gemboss:editor-split",{width:o})}return o}}function y(t){var e=g(t);return w(e&&e.getAttribute("aria-valuenow"),384)}function F(t){var e=c(t),r=g(t);if(!(!e||!r)){r.hasAttribute("data-gb-max")||r.setAttribute("data-gb-max",r.getAttribute("aria-valuemax")||"680");var i=e.getAttribute("data-gb-storage"),n=y(t);if(i)try{var a=Number(localStorage.getItem(i));a>=v(t).min&&a<=v(t).cap&&(n=a)}catch{}C(t,n),e.classList.contains("is-chat-collapsed")||d(t,n,!1)}}function O(t,e){var r=c(t);if(r&&!(e.button!==0||e.isPrimary===!1)){e.preventDefault();var i=r.getBoundingClientRect().left,n=x(t),a=0,s=y(t);document.body.classList.add("ui-resizing");var f=function(E){s=E.clientX-i-k,a||(a=requestAnimationFrame(function(){a=0,d(t,s,!1,n)}))},o=function(){a&&cancelAnimationFrame(a),document.body.classList.remove("ui-resizing"),removeEventListener("pointermove",f),removeEventListener("pointerup",o),removeEventListener("pointercancel",o),removeEventListener("blur",o),d(t,s,!0,n)};addEventListener("pointermove",f),addEventListener("pointerup",o),addEventListener("pointercancel",o),addEventListener("blur",o)}}function D(t,e){if(!(e.altKey||e.metaKey||e.ctrlKey)){var r=e.shiftKey?64:16,i=y(t),n=v(t);if(e.key==="ArrowLeft")i-=r;else if(e.key==="ArrowRight")i+=r;else if(e.key==="Home")i=n.min;else if(e.key==="End")i=x(t);else return;e.preventDefault(),d(t,i,!0)}}function T(t,e){var r=c(t);if(r){var i=!r.classList.contains("is-chat-collapsed");r.classList.toggle("is-chat-collapsed",i),i?r.style.setProperty("--gemboss-chat-w","0px"):d(t,q(t),!1),e.classList.toggle("collapsed",i),e.setAttribute("aria-pressed",String(i)),e.setAttribute("aria-label",i?"Show chat panel":"Hide chat panel"),e.title=i?"Show chat panel":"Hide chat \u2014 full-width preview";var n=e.querySelector("rect[fill='currentColor']");n&&(n.style.display=i?"none":""),l(t,"gemboss:editor-chat",{collapsed:i})}}function U(t,e){var r=e.closest(".gemboss-pv-devices");r&&(r.querySelectorAll(".gemboss-pv-device").forEach(function(i){var n=i===e;i.classList.toggle("active",n),i.setAttribute("aria-pressed",String(n))}),l(t,"gemboss:editor-device",{device:e.getAttribute("data-device")||(e.title||"").toLowerCase()}))}function K(t,e){var r=e.getAttribute("data-pane")||(Array.prototype.indexOf.call(e.parentNode.children,e)===0?"chat":"preview"),i=c(t);i&&(i.setAttribute("data-mobile-pane",r),e.parentNode.querySelectorAll(".gemboss-editor-mobile-tab").forEach(function(n){var a=n===e;n.classList.toggle("active",a),n.setAttribute("aria-selected",String(a))}),l(t,"gemboss:editor-pane",{pane:r}))}var u=null;function M(t){var e=t.trigger.getBoundingClientRect();t.menu.style.top=e.bottom+6+"px",t.menu.style.right=Math.max(8,innerWidth-e.right)+"px";var r=t.menu.getBoundingClientRect(),i=e.right-r.right;r.left+i<8&&(i=8-r.left),Math.abs(i)>=.5&&(t.menu.style.right=parseFloat(t.menu.style.right)-i+"px")}function m(t){var e=u;e&&(u=null,e.menu.hidden=!0,e.trigger.setAttribute("aria-expanded","false"),e.trigger.classList.remove("active"),t&&e.trigger.focus(),l(e.root,"gemboss:editor-menu",{open:!1,menu:e.menu}))}function H(t,e){var r=e.nextElementSibling;if(!r||!r.classList.contains("gemboss-editor-menu"))return!1;var i=u&&u.trigger===e;return m(!1),i||(u={root:t,trigger:e,menu:r},r.hidden=!1,e.setAttribute("aria-expanded","true"),e.classList.add("active"),M(u),l(t,"gemboss:editor-menu",{open:!0,menu:r})),!0}var P=!1;function j(){P||(P=!0,document.addEventListener("pointerdown",function(t){u&&!u.menu.contains(t.target)&&!u.trigger.contains(t.target)&&m(!1)}),addEventListener("keydown",function(t){t.key!=="Escape"||!u||t.isComposing||t.keyCode===229||(t.preventDefault(),t.stopImmediatePropagation(),m(!0))},!0),addEventListener("resize",function(){u&&M(u)}),addEventListener("blur",function(){m(!1)}))}function G(t){var e=t&&t.classList&&t.classList.contains("gemboss-editor")?[t]:Array.prototype.slice.call((t||document).querySelectorAll(".gemboss-editor"));return j(),e.forEach(function(r){if(!(p?p.has(r):r._gbEditor)){p?p.add(r):r._gbEditor=!0,F(r);var i=c(r);i&&typeof ResizeObserver=="function"&&new ResizeObserver(function(){i.classList.contains("is-chat-collapsed")||d(r,q(r),!1)}).observe(i),r.addEventListener("pointerdown",function(n){var a=n.target.closest(".gemboss-editor-resizer");a&&a===g(r)&&O(r,n)}),r.addEventListener("keydown",function(n){n.target.classList&&n.target.classList.contains("gemboss-editor-resizer")&&n.target===g(r)&&D(r,n)}),r.addEventListener("click",function(n){var a=n.target,s;if(s=a.closest(".gemboss-editor-chat-toggle"))return T(r,s);if(s=a.closest(".gemboss-pv-devices .gemboss-pv-device"))return U(r,s);if(s=a.closest(".gemboss-editor-mobile-tab"))return K(r,s);if((s=a.closest('[aria-haspopup="menu"]'))&&H(r,s)){n.stopPropagation();return}u&&(s=a.closest(".gemboss-editor-menu-item"))&&u.menu.contains(s)&&m(!0)})}}),e.length}b.initEditorFrame=G;var V=/^#[0-9a-fA-F]{6}$/,I=function(t){var e=t.getAttribute("data-value");return e??(t.textContent||"").trim()},N=function(t){return Array.prototype.filter.call(t.children,function(e){return e.getAttribute("role")==="tab"})};function W(t,e){var r=t.parentNode,i=N(r),n=i.some(function(a){return a.hasAttribute("tabindex")});i.forEach(function(a){var s=a===t;a.setAttribute("aria-selected",String(s)),a.classList.contains("gemboss-ei-tab")&&a.classList.toggle("on",s),n&&a.setAttribute("tabindex",s?"0":"-1");var f=a.getAttribute("aria-controls"),o=f&&document.getElementById(f);o&&(o.hidden=!s)}),e&&t.focus(),l(t,"gemboss:editor-tab",{tab:I(t),index:i.indexOf(t)})}function $(t,e){if(!(e.key!=="ArrowRight"&&e.key!=="ArrowLeft")){var r=N(t);if(r.some(function(a){return a.hasAttribute("tabindex")})){var i=r.findIndex(function(a){return a.getAttribute("aria-selected")==="true"}),n=e.key==="ArrowRight"?(i+1)%r.length:(i-1+r.length)%r.length;e.preventDefault(),W(r[n],!0)}}}function X(t){var e=t.getAttribute("data-open")!=="open";t.setAttribute("data-open",e?"open":"closed"),t.setAttribute("aria-expanded",String(e));var r=t.querySelector(".gemboss-ei-chev");r&&(r.textContent=e?"\u25BE":"\u25B8"),l(t,"gemboss:editor-group",{open:e})}function J(t){var e=t.closest(".gemboss-ei-seg");Array.prototype.forEach.call(e.querySelectorAll("button"),function(r){var i=r===t;r.classList.toggle("active",i),r.classList.toggle("on",i),r.hasAttribute("aria-pressed")&&r.setAttribute("aria-pressed",String(i))}),l(e,"gemboss:editor-seg",{value:I(t)})}function _(t){var e=t.querySelector(".gemboss-ei-swatch"),r=t.querySelector("input:not([type=color])");if(!(!e||!r)){var i=r.value,n=!i||V.test(i);e.value=i&&n?i:"#000000",e.disabled=r.disabled||!n,n?e.removeAttribute("title"):e.title=i+" \u2014 not a plain 6-digit hex; edit it in the box below"}}function Q(t){for(var e=t.labels||[],r=null,i=0;i<e.length&&!r;i++)r=e[i].querySelector(".gemboss-ei-rangeval");r&&(r.textContent=t.value+(r.getAttribute("data-suffix")||""))}function Y(t){var e={},r=["t","r","b","l"];return Array.prototype.forEach.call(t.querySelectorAll("input"),function(i,n){e[r[n]]=i.value.trim()}),e}function B(t){return'url("'+t.replace(/["\\]/g,"\\$&").replace(/[\u0000-\u001f\u007f]/g,function(e){return"\\"+e.charCodeAt(0).toString(16)+" "})+'")'}b.cssUrl=B;function Z(t,e){var r=t.querySelector(".gemboss-ei-img-thumb");r&&(r.style.backgroundImage=e?B(e):"",r.textContent=e?"":"\u2014")}function R(t,e){var r=t.getAttribute("data-gb-value")!=null?t.getAttribute("data-gb-value"):e.defaultValue;e.value!==r&&(t.setAttribute("data-gb-value",e.value),Z(t,e.value),l(t,"gemboss:editor-image",{value:e.value}))}var A=typeof WeakSet=="function"?new WeakSet:null,L=function(t){return t.__gbPanel?!1:(t.__gbPanel=!0,!0)};function ee(t){var e=t||document;return(A?A.has(e):e._gbPanel)?0:(A?A.add(e):e._gbPanel=!0,Array.prototype.forEach.call(e.querySelectorAll(".gemboss-ei-color"),_),e.addEventListener("click",function(r){var i=r.target,n;if(!(!i.closest||!L(r))){if(n=i.closest('.gemboss-ec-tabs > [role="tab"], .gemboss-ei-tabs > [role="tab"]'))return W(n,!1);if(n=i.closest(".gemboss-ei-gh"))return X(n);if((n=i.closest(".gemboss-ei-seg button"))&&!n.disabled)return J(n);if((n=i.closest("[data-gb-image-clear]"))&&!n.disabled){var a=n.closest(".gemboss-ei-img"),s=a&&a.querySelector(".gemboss-ei-img-ctl input");s&&(s.value="",R(a,s))}}}),e.addEventListener("keydown",function(r){var i=r.target;if(!(!i.closest||!L(r))){var n=i.closest('.gemboss-ec-tabs[role="tablist"], .gemboss-ei-tabs[role="tablist"]');if(n&&i.getAttribute("role")==="tab")return $(n,r);r.key==="Enter"&&i.tagName==="INPUT"&&i.type!=="color"&&i.type!=="range"&&i.closest(".gemboss-ei-color, .gemboss-ei-edgebox, .gemboss-ei-img")&&(r.preventDefault(),i.blur())}}),e.addEventListener("input",function(r){var i=r.target;if(!(!i.closest||!L(r))){if(i.classList.contains("gemboss-ei-range")){Q(i),l(i,"gemboss:editor-range",{value:Number(i.value)});return}if(i.classList.contains("gemboss-ei-swatch")){var n=i.closest(".gemboss-ei-color"),a=n&&n.querySelector("input:not([type=color])");a&&(a.value=i.value),l(n||i,"gemboss:editor-color",{value:i.value})}}}),e.addEventListener("focusout",function(r){var i=r.target;if(!(!i.closest||i.tagName!=="INPUT"||!L(r))){var n;if(i.type!=="color"&&(n=i.closest(".gemboss-ei-color"))){_(n),l(n,"gemboss:editor-color",{value:i.value});return}if(n=i.closest(".gemboss-ei-edgebox")){l(n,"gemboss:editor-edges",Y(n));return}(n=i.closest(".gemboss-ei-img"))&&R(n,i)}}),1)}b.initEditorPanel=ee})(typeof globalThis<"u"?globalThis:this);