@gemboss/ui 0.2.0 → 0.3.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.
package/src/chat.css ADDED
@@ -0,0 +1,427 @@
1
+ /*
2
+ * The chat BODY — what an answer looks like, wherever a GemBoss surface puts a chat (01/10/2026).
3
+ *
4
+ * Chris: every surface's AI assistant (gemcare, content-radar, partner-radar, GemCommunity) must be built from the
5
+ * GemBoss UI framework. Until today only the FRAME was shared (assistant.css: popup / bar / full screen; shell.css:
6
+ * the thread rail); the body lived in the admin's gemboss-shell.css and the exporter left it out on purpose, so every
7
+ * surface drew its own bubbles, markdown and composer. These are those rules, moved here VERBATIM (same selectors,
8
+ * same values, same order) — the admin @imports this file at the top of its shell, so it is still the one owner.
9
+ *
10
+ * What is here: the header (`.gemboss-ec-head/title/sub`), the message list (`.gemboss-ec-scroll`), messages and
11
+ * bubbles (`.gemboss-ec-msg.user|.assistant`, `.gemboss-ec-bubble`), the working indicator and the streaming cursor
12
+ * (`.gemboss-ec-pending*`, `.gemboss-ec-cursor`), markdown (`.gemboss-md-*`, `.gemboss-ec-md-link`), step narration
13
+ * (`.gemboss-narr*`), starters and option chips (`.gemboss-ec-starter(s)`, `.gemboss-ec-chip(s)`), and the composer
14
+ * (`.gemboss-ec-input` with its textarea and submit button).
15
+ * What is NOT: the panel's geometry (`.gemboss-ec` width, editor columns, tabs), attachments, CTA / action cards,
16
+ * the engine picker and work mode — those are still the admin's, and a surface brings its own. ONE exception, on
17
+ * purpose: the chat panel's touch / mouse TARGET blocks (`@media (pointer: coarse|fine)` below) name every control
18
+ * of the panel, the admin-only ones (tabs, upload, engine picker, work mode, attach, stop) included. They are one
19
+ * rule per pointer kind; split in two they wrote their declarations twice and the admin's shell budget had no room.
20
+ * Those selectors match nothing on a surface without those controls. (`.gemboss-ec-workmode`: the click-area
21
+ * `::before` is here, its label-sizing `::after` stays the admin's.)
22
+ *
23
+ * Markup: see the catalog's "Thân chat" section. Behaviour (streaming, stop, keyboard) belongs to the surface.
24
+ */
25
+
26
+ .gemboss-ec-head { padding: 16px 16px; border-bottom: 1px solid var(--gemboss-divider); display: flex; flex-direction: column; gap: 2px; }
27
+ .gemboss-ec-title { font-family: var(--gemboss-serif); font-size: 15px; font-weight: 600; }
28
+ .gemboss-ec-sub { font-size: 12px; color: var(--gemboss-text-muted); }
29
+ .gemboss-ec-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scrollbar-gutter: stable; }
30
+ /* Visible thin scrollbar on the chat message list — the default macOS overlay bar is
31
+ hidden until you actively scroll, so the list reads as "can't scroll" (esp. on the
32
+ dedicated Assistant page, a fixed-height chat where the page itself doesn't scroll).
33
+ A persistent thin bar makes the scroll affordance obvious; matches the composer input. */
34
+ .gemboss-ec-scroll { scrollbar-width: thin; scrollbar-color: rgba(31,30,29,0.22) transparent; }
35
+ .gemboss-ec-scroll::-webkit-scrollbar { width: 9px; }
36
+ .gemboss-ec-scroll::-webkit-scrollbar-track { background: transparent; }
37
+ .gemboss-ec-scroll::-webkit-scrollbar-thumb { background: rgba(31,30,29,0.2); border-radius: 6px; border: 2px solid transparent; background-clip: content-box; }
38
+ .gemboss-ec-msg { display: flex; flex-direction: column; gap: 8px; max-width: 100%; }
39
+ .gemboss-ec-msg.user { align-items: flex-end; }
40
+ .gemboss-ec-msg.assistant { align-items: flex-start; }
41
+ .gemboss-ec-bubble {
42
+ /* Cap reading measure: a chat reply stays at a readable line length, not the bubble's full 90%. */
43
+ font-size: var(--gemboss-fs-body); line-height: 1.5; padding: 12px 12px; border-radius: 16px; max-width: min(90%, 68ch);
44
+ white-space: pre-wrap; word-break: break-word;
45
+ }
46
+ .gemboss-ec-msg.user .gemboss-ec-bubble { background: var(--gemboss-primary); color: #fff; border-bottom-right-radius: 4px; }
47
+ .gemboss-ec-msg.assistant .gemboss-ec-bubble { background: var(--gemboss-surface); color: var(--gemboss-text); border: 1px solid var(--gemboss-divider); border-bottom-left-radius: 4px; }
48
+ .gemboss-ec-input button[type="submit"] svg { width: 18px; height: 18px; fill: currentColor; }
49
+ /* Live "working" heartbeat — the Claude-Code-style running asterisk shown before the first token.
50
+ Slim, muted, single line so the chat stays clean; the motion (pulsing ✳, crossfading verb, live
51
+ counter) is what makes the wait feel fast instead of frozen. */
52
+ .gemboss-ec-pending { display: inline-flex; align-items: center; gap: 8px; padding: 6px 2px; font-size: var(--gemboss-fs-caption); color: var(--gemboss-text-muted); }
53
+ .gemboss-ec-pending-star { color: var(--gemboss-primary); font-size: 12px; line-height: 1; animation: gemboss-star-pulse 1.1s ease-in-out infinite; }
54
+ .gemboss-ec-pending-verb { color: var(--gemboss-text); animation: gemboss-verb-in 0.4s ease-out; }
55
+ .gemboss-ec-pending-meta { font-variant-numeric: tabular-nums; opacity: 0.7; font-size: var(--gemboss-fs-caption); }
56
+ @keyframes gemboss-star-pulse { 0%,100% { transform: scale(0.85); opacity: 0.55; } 50% { transform: scale(1.15); opacity: 1; } }
57
+ @keyframes gemboss-verb-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } }
58
+ /* Typewriter cursor — a thin blinking bar trailing the answer while tokens stream in. */
59
+ .gemboss-ec-cursor { display: inline-block; width: 2px; height: 1.05em; margin-left: 2px; vertical-align: -2px; background: var(--gemboss-primary); border-radius: 2px; animation: gemboss-cursor-blink 1s steps(1) infinite; }
60
+ @keyframes gemboss-cursor-blink { 0%,45% { opacity: 1; } 50%,95% { opacity: 0.15; } 100% { opacity: 1; } }
61
+ @media (prefers-reduced-motion: reduce) {
62
+ .gemboss-ec-pending-star, .gemboss-ec-cursor { animation: none; }
63
+ }
64
+ /* Markdown link inside the answer bubble — a friendly inline link, never a raw path. */
65
+ .gemboss-ec-md-link { color: var(--gemboss-primary-dark); font-weight: 600; text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--gemboss-primary) 35%, transparent); cursor: pointer; }
66
+ .gemboss-ec-md-link:hover { border-bottom-color: var(--gemboss-primary); }
67
+ /* Suggestion starters = compact wrapping CHIPS (pills), not stacked full-width boxes —
68
+ reads as a chat's quick-reply chips and stops one prompt eating a whole row. */
69
+ .gemboss-ec-starters { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
70
+ .gemboss-ec-starter {
71
+ text-align: left; font-size: 13px; padding: 6px 12px; border-radius: var(--gemboss-radius-full); cursor: pointer;
72
+ background: var(--gemboss-surface); border: 1px solid var(--gemboss-divider); color: var(--gemboss-text);
73
+ white-space: nowrap; max-width: 100%;
74
+ }
75
+ .gemboss-ec-starter:hover { border-color: var(--gemboss-primary); background: rgba(86, 97, 245, 0.06); }
76
+ /* (removed) dead legacy rail styles — `.icon-nav-assistant` + the hover-flyout submenu
77
+ (`.icon-nav-flyout*`). The current nav (IconNav.tsx) is a persistent 240px labeled
78
+ sidebar using `.gnav-*` classes with route-driven sub-nav; there is no 72px icon rail
79
+ and no hover flyout, so these selectors matched nothing. */
80
+
81
+ /* Composer = ONE bordered rounded shell (ChatGPT/Claude-style): the textarea sits borderless
82
+ inside it and the attach (📎) + send (↑) buttons float within the same box — cleaner than the
83
+ old three-separate-boxes row. The shell is inset from the panel edges (margin) and lifts its
84
+ border to primary on focus-within, so the whole composer reads as one focused field. */
85
+ /* TWO ROWS when there are chips (ChatComposer): the textarea on its own full-width row, the toolbar
86
+ under it. The toolbar is where chips of any length live, and when they shared a row with the text
87
+ box the send button was the thing that gave way — measured 22/09/2026 at 320px, it wrapped to a
88
+ THIRD line. With no chips at all, ONE row — see `:has` below.
89
+ 8px inside, not 6: at 6 the text sat 13px from the border and the box read as cramped. What the
90
+ toolbar SAYS at each width is ChatComposer's job (data-density), measured rather than guessed. */
91
+ .gemboss-ec-input {
92
+ display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
93
+ margin: 12px; padding: 8px;
94
+ border: 1px solid var(--gemboss-divider); border-radius: 16px;
95
+ background: var(--gemboss-surface);
96
+ transition: border-color var(--gemboss-transition-fast) ease, box-shadow var(--gemboss-transition-fast) ease;
97
+ }
98
+ .gemboss-ec-input:focus-within {
99
+ border-color: var(--gemboss-primary);
100
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--gemboss-primary) 12%, transparent);
101
+ }
102
+ .gemboss-ec-input textarea {
103
+ flex: 1 0 100%; min-width: 0; width: 100%; box-sizing: border-box; resize: none; border-radius: 0; background: transparent;
104
+ /* The CHIP's box model — a transparent 1px border and 8px of padding — so the first letter typed
105
+ lines up with the first letter of "Claude Code" below it. It was 6px with no border: the text
106
+ started 3px left of the chips, measured on every host at every width, and the box read as two
107
+ columns. (Box model rather than nine pixels of padding: nine is off the spacing scale the CI guard holds.) */
108
+ border: 1px solid transparent;
109
+ padding: 6px 8px; font-size: var(--gemboss-fs-body); font-family: inherit;
110
+ /* Starts at one line; GemChat's auto-grow JS expands it up to ~3 lines (max-height),
111
+ then scrolls inside (ChatGPT/Gemini-style). overflow-y is hidden until the JS flips
112
+ it to auto past the cap, so a 1–2 line message never shows a (rounding) scrollbar. */
113
+ min-height: 0; max-height: 84px; line-height: 1.5; overflow-y: hidden;
114
+ scrollbar-width: thin; scrollbar-color: rgba(31,30,29,0.22) transparent; /* thin, not the OS bar */
115
+ }
116
+ .gemboss-ec-input textarea::-webkit-scrollbar { width: 7px; }
117
+ .gemboss-ec-input textarea::-webkit-scrollbar-track { background: transparent; }
118
+ .gemboss-ec-input textarea::-webkit-scrollbar-thumb { background: rgba(31,30,29,0.2); border-radius: 4px; border: 2px solid transparent; background-clip: content-box; }
119
+ .gemboss-ec-input textarea:focus { outline: none; }
120
+ /* Send button — a filled primary circle floating at the shell's bottom-right. inline-flex centers
121
+ the ↑ SVG (the old rule had no flex-centering, so the icon sat high).
122
+
123
+ SCOPED TO THE SUBMIT BUTTON, and that word is load-bearing. This rule used to read
124
+ `.gemboss-ec-input button`, which reached EVERY button in the composer — including whatever a host
125
+ passes in through `composerLeading`, a slot that exists precisely so hosts can put their own
126
+ controls here. The 📎 attach button already had to fight it back with a class of its own (see the
127
+ comment above `.gemboss-ec-attach-btn`), which was the first sign that this selector owns more than
128
+ it should. The second was the engine picker: a Polaris Button, rendered into the slot, came out as a
129
+ 34px black circle with its label ("Claude Code · Opus") clipped to three characters. Nothing was
130
+ wrong with the picker — this rule was painting it.
131
+
132
+ §9.3: one visual owner per component. The composer owns its own send button and nothing that is
133
+ handed to it. Guarded by composer-owns-only-its-own-buttons.test.ts.
134
+
135
+ 44px, not 34: it is a CONTROL (§9.1, Apple HIG / WCAG 2.5.5 AAA). This branch had already raised
136
+ it — but on the OLD unscoped selector, the very rule main had just narrowed. The two changes were
137
+ fixing different faults in one declaration, so both are kept: main's scope, this branch's size. */
138
+ .gemboss-ec-input button[type="submit"] {
139
+ flex: 0 0 auto; width: 44px; height: 44px; min-width: 44px; min-height: 44px; border-radius: 50%; border: 0; cursor: pointer;
140
+ display: inline-flex; align-items: center; justify-content: center;
141
+ background: var(--gemboss-primary); color: #fff; font-size: 16px; font-weight: 700;
142
+ transition: background var(--gemboss-transition-fast) ease, filter var(--gemboss-transition-fast) ease;
143
+ }
144
+ .gemboss-ec-input button[type="submit"]:hover:not(:disabled) { background: var(--gemboss-primary-dark); }
145
+ .gemboss-ec-input button[type="submit"]:active:not(:disabled) { background: var(--gemboss-primary-dark); filter: brightness(0.94); }
146
+ .gemboss-ec-input button[type="submit"]:focus-visible { outline: 2px solid var(--gemboss-primary); outline-offset: 2px; }
147
+ /* Disabled (empty input): a clear neutral grey circle with a muted arrow — reads as
148
+ "inactive" rather than a washed-out faint-primary blob. */
149
+ .gemboss-ec-input button[type="submit"]:disabled { background: var(--gemboss-divider); color: var(--gemboss-text-muted); opacity: 1; cursor: default; }
150
+ /* ── Narration timeline ── */
151
+ .gemboss-narr { list-style: none; margin: 0; padding: 8px 0 0; display: flex; flex-direction: column; gap: 8px; }
152
+ .gemboss-narr-row { display: flex; align-items: center; gap: 8px; font-size: var(--gemboss-fs-caption); color: var(--gemboss-text-muted); }
153
+ .gemboss-narr-dot { width: 16px; height: 16px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; flex: 0 0 auto; }
154
+ .gemboss-narr-row.ok .gemboss-narr-dot { background: var(--gemboss-active-bg); color: var(--gemboss-active-fg); }
155
+ .gemboss-narr-row.failed .gemboss-narr-dot { background: #fde8e6; color: var(--gemboss-danger); }
156
+ .gemboss-narr-row.running .gemboss-narr-dot { background: transparent; }
157
+ /* THE ENGINE'S MARK, in the dot's slot. No pill behind it: the ✓/✕ dots earn their background by
158
+ being a glyph in a circle, and a logo in a coloured circle reads as a badge the engine awarded
159
+ itself. It takes the ROW's colour, so a failed turn's mark goes red with everything else — a mark
160
+ that stayed brand-coloured over a failure would be the one cheerful thing on a broken row. */
161
+ .gemboss-narr-dot-engine { background: transparent !important; color: inherit; }
162
+ .gemboss-narr-row.ok .gemboss-narr-dot-engine { color: var(--gemboss-text); }
163
+ .gemboss-narr-row.failed .gemboss-narr-dot-engine { color: var(--gemboss-danger); }
164
+ .gemboss-narr-row.ok .gemboss-narr-text, .gemboss-narr-row.failed .gemboss-narr-text { color: var(--gemboss-text); }
165
+ /* A message the seller typed INTO the running turn. It reads as THEIR line, not as a step the agent
166
+ took: full-strength text (the timeline is muted by default) against the page's own text colour, and
167
+ a `❯` prompt caret instead of a status dot. It must be legible at a glance while the agent is still
168
+ writing above it — a seller scanning a long timeline needs to find the moment they interrupted. */
169
+ .gemboss-narr-row.steer .gemboss-narr-dot { background: transparent; color: var(--gemboss-text-muted); }
170
+ /* `anywhere`, not `normal`: a seller correcting a turn pastes URLs and product handles, and one
171
+ unbroken 60-character token in a 320px chat column is a row that pushes the panel sideways. Measured
172
+ 2026-09-23 — the default `overflow-wrap: normal` on this row does not break such a token. */
173
+ .gemboss-narr-steer { color: var(--gemboss-text); font-weight: 500; overflow-wrap: anywhere; }
174
+ .gemboss-narr-spin { width: 11px; height: 11px; border: 2px solid var(--gemboss-divider-soft); border-top-color: var(--gemboss-primary); border-radius: 50%; animation: gemboss-spin 0.7s linear infinite; }
175
+ @keyframes gemboss-spin { to { transform: rotate(360deg); } }
176
+ /* Live-reasoning ("thinking") row: muted + italic while streaming, collapsible once settled. */
177
+ .gemboss-narr-row.thinking { align-items: flex-start; }
178
+ .gemboss-narr-row.thinking .gemboss-narr-dot { color: var(--gemboss-text-muted); background: transparent; }
179
+ .gemboss-narr-thinking-live { font-style: italic; color: var(--gemboss-text-muted); white-space: pre-wrap; opacity: 0.9; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
180
+ .gemboss-narr-thinking { font-size: var(--gemboss-fs-caption); }
181
+ .gemboss-narr-thinking > summary { cursor: pointer; color: var(--gemboss-text-muted); list-style: none; user-select: none; }
182
+ .gemboss-narr-thinking > summary::-webkit-details-marker { display: none; }
183
+ .gemboss-narr-thinking > summary::before { content: "▸ "; }
184
+ .gemboss-narr-thinking[open] > summary::before { content: "▾ "; }
185
+ .gemboss-narr-thinking-body { margin-top: 6px; padding: 8px 8px; border-left: 2px solid var(--gemboss-divider-soft); color: var(--gemboss-text-muted); font-style: italic; white-space: pre-wrap; line-height: 1.5; }
186
+ /* TOOL TRANSCRIPT rows — the Claude-Code shape: a monospace call line, its return value folded
187
+ behind a disclosure. Muted on purpose: the transcript is evidence you can audit, not the answer. */
188
+ .gemboss-narr-row.trace code { font-family: var(--gemboss-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: var(--gemboss-fs-caption); word-break: break-word; }
189
+ .gemboss-narr-trace-call { color: var(--gemboss-text-muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
190
+ .gemboss-narr-trace { font-size: var(--gemboss-fs-caption); min-width: 0; }
191
+ .gemboss-narr-trace > summary { cursor: pointer; color: var(--gemboss-text-muted); list-style: none; user-select: none;
192
+ /* A tool call with real arguments is long and the chat panel is narrow: a full payload wrapped to five
193
+ lines buries the rows around it. Clamp while closed; opening the row shows the call in full. */
194
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
195
+ .gemboss-narr-trace[open] > summary { display: block; overflow: visible; }
196
+ .gemboss-narr-trace > summary::-webkit-details-marker { display: none; }
197
+ /* The fold is now the ONLY way to see a payload or a finished thought (both start closed), so its
198
+ summary is a target: ≥24px tall, the list-link tier of Gems/CLAUDE.md §9.1 (WCAG 2.5.8). Measured
199
+ 21px before, 13/09. */
200
+ .gemboss-narr-trace > summary, .gemboss-narr-thinking > summary { min-height: 24px; box-sizing: border-box; padding-block: 2px; }
201
+ .gemboss-narr-trace > summary::before { content: "▸ "; }
202
+ .gemboss-narr-trace[open] > summary::before { content: "▾ "; }
203
+ .gemboss-narr-trace-body { margin-top: 6px; padding: 8px; border-left: 2px solid var(--gemboss-divider-soft); color: var(--gemboss-text-muted); white-space: pre-wrap; line-height: 1.5; overflow-x: auto; }
204
+ @media (max-width: 640px) {
205
+ /* Composer auto-grows from one line (see GemChat) — no fixed floor needed here. */
206
+ .gemboss-ec-input textarea { min-height: 0; }
207
+ .gemboss-ec-scroll { padding: 12px; }
208
+ }
209
+ /* ── Chat option chips (persona/goal polling) ── */
210
+ .gemboss-ec-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
211
+ /* 44px because a chip is a CONTROL, not a link in a list — Apple HIG / WCAG 2.5.5 AAA, Gems CLAUDE.md
212
+ §9.1. Measured 2026-09-09 at 390px and 1366px: padding alone rendered these at 34px, ten short, and
213
+ they are the only way to answer a poll or resume an unfinished run on a phone. `min-height` rather
214
+ than `height` so a wrapped two-line label still grows instead of clipping. */
215
+ .gemboss-ec-chip {
216
+ background: var(--gemboss-surface); border: 1px solid var(--gemboss-divider); border-radius: var(--gemboss-radius-full);
217
+ padding: 8px 16px; font-size: 13px; cursor: pointer; color: var(--gemboss-text);
218
+ min-height: 44px;
219
+ }
220
+ .gemboss-ec-chip:hover { border-color: var(--gemboss-primary); color: var(--gemboss-active-fg); }
221
+ .gemboss-ec-chip:disabled { opacity: 0.5; cursor: default; }
222
+ /* ════ Mouse targets — the same 44px, bought with an overlay instead of a bigger box ═══════
223
+ §9.1 asks 44px of a CONTROL, and it does not say "on touch only". The chat panel's controls are
224
+ 28-37px under a mouse, which clears WCAG 2.5.8 AA (24px) and not 2.5.5 AAA (44px). The dense look
225
+ is deliberate and worth keeping, so the TARGET grows and the ink does not — the same trick the
226
+ editor topbar already uses on its device buttons.
227
+
228
+ WHICH ONES, AND WHY NOT THE REST. A target that reaches into a neighbour steals clicks meant for
229
+ it, which is worse than a small one. So this was decided by measuring the gap between every pair
230
+ of vertically adjacent controls at 390 / 768 / 1366 and asking whether BOTH could grow into it —
231
+ a control's own clearance is not enough, because its neighbour is growing too. Measured
232
+ 2026-09-09, three pairs could not:
233
+
234
+ starter chips, row to row gap 8px, both need 7 -> the ROW GAP goes to 16px below, which buys
235
+ it without changing how the chips look
236
+ textarea / engine picker gap 8px, needs 10 -> the textarea does not grow (see below)
237
+ textarea / send gap 6px, needs 9 -> same
238
+
239
+ THE MESSAGE BOX IS EXCLUDED ON PURPOSE. It is a full-width field 37px tall: its AREA is already
240
+ enormous, the only dimension short of 44 is its height, and it is the one control here you reach
241
+ by clicking INTO text rather than at a point. Growing it also collides with both of its
242
+ neighbours. With it left alone, everything else fits: send needs 5 of the 6px above it, the
243
+ picker needs 8 of 8.
244
+
245
+ Height only. Horizontally these controls are already 34-196px wide, and an overlay that also grew
246
+ sideways would collide with the chip beside it, which is 8px away. */
247
+ @media (pointer: fine) {
248
+ .gemboss-ec-starters { row-gap: 16px; }
249
+ .gemboss-ec-tab,
250
+ .gemboss-ec-starter,
251
+ .gemboss-ec-upload,
252
+ .gemboss-ec-enginepick button,
253
+ .gemboss-ec-workmode,
254
+ .gemboss-ec-input .gemboss-ec-attach-btn,
255
+ .gemboss-ec-stop,
256
+ /* Shares the toolbar row with Stop, which is already 44px tall — so a 44px overlay here grows into
257
+ a row that is that tall already, and reaches into nothing above or below it. */
258
+ .gemboss-ec-stop-send,
259
+ .gemboss-ec-input button[type="submit"] { position: relative; }
260
+ .gemboss-ec-tab::after,
261
+ .gemboss-ec-starter::after,
262
+ .gemboss-ec-upload::after,
263
+ .gemboss-ec-enginepick button::after,
264
+ /* `::before` on the work-mode wrapper: its `::after` is the invisible label that SIZES it, and a
265
+ second use of the same pseudo-element would silently replace that. */
266
+ .gemboss-ec-workmode::before,
267
+ .gemboss-ec-input .gemboss-ec-attach-btn::after,
268
+ .gemboss-ec-stop::after,
269
+ .gemboss-ec-stop-send::after,
270
+ .gemboss-ec-input button[type="submit"]::after {
271
+ content: "";
272
+ position: absolute;
273
+ left: 0; right: 0;
274
+ top: 50%;
275
+ transform: translateY(-50%);
276
+ height: 44px;
277
+ /* No background at all: a pseudo-element is transparent already, and the `background` shorthand
278
+ is banned in this family because it resets `background-image` — the work-mode caret. My own
279
+ guard from #2158 caught this line, which is the guard doing exactly what it was written for. */
280
+ pointer-events: auto;
281
+ /* Above the panel chrome it overhangs — the chrome is not interactive, so a click landing on the
282
+ control the overlay belongs to is the point.
283
+ This does NOT rescue a control sitting flush against the top of `.gemboss-ec-scroll`: an
284
+ overflow container clips its absolutely-positioned descendants, and z-index cannot cross that.
285
+ Measured at 768px, where the first row of starter chips begins at the exact pixel the header
286
+ ends: their target is 37px until the list scrolls, and 44px after. A scroll position, not a
287
+ layout constant — the same chips measure 44px at 390, 1366 and 1920. Left alone rather than
288
+ bought with padding on the scroll area, which every seller would see all the time to fix a
289
+ target in one transient position. */
290
+ z-index: 1;
291
+ }
292
+ }
293
+ @media (pointer: coarse) {
294
+ .gemboss-ec-input button[type="submit"] { width: 44px; height: 44px; font-size: 17px; }
295
+ .gemboss-ec-starter, .gemboss-ec-opt { min-height: 44px; }
296
+ .gemboss-ec-input textarea { min-height: 44px; }
297
+ }
298
+ /* ── The answer bubble's markdown ────────────────────────────────────────────────────────────────
299
+ *
300
+ * The bubble understood bold, links and line breaks. The models writing into it do not restrict
301
+ * themselves to three things: a question about pages came back as a markdown TABLE and rendered as
302
+ * literal pipes down the page. These are the styles for what it actually writes. */
303
+
304
+ .gemboss-md-p { margin: 0 0 8px; }
305
+ .gemboss-md-p:last-child { margin-bottom: 0; }
306
+ /* Demoted one level in the markup (h1 → h3) because the bubble sits under the page's own headings.
307
+ * The SIZE hierarchy is kept here, because that is what a reader uses to skim. */
308
+ .gemboss-md-h { margin: 16px 0 6px; font-weight: 650; line-height: 1.3; }
309
+ .gemboss-md-h:first-child { margin-top: 0; }
310
+ /* RESTORED after #3370 deleted these three. MarkdownLite.tsx builds the class by interpolation —
311
+ * `gemboss-md-h gemboss-md-h${b.level}` — so the literal `gemboss-md-h1` exists in no source file
312
+ * and in no compiled chunk, and all three of that PR's detection passes read them as dead. The gate
313
+ * now carries the prefix rule that catches this shape. Do not "clean" these up. */
314
+ .gemboss-md-h1 { font-size: 17px; }
315
+ .gemboss-md-h2 { font-size: 15px; }
316
+ .gemboss-md-h3 { font-size: var(--gemboss-fs-body); color: var(--gemboss-text-subdued, #616161); }
317
+ .gemboss-md-list { margin: 0 0 8px; padding-left: 20px; }
318
+ .gemboss-md-list li { margin: 2px 0; }
319
+ .gemboss-md-quote {
320
+ margin: 0 0 8px;
321
+ padding: 2px 0 2px 12px;
322
+ border-left: 3px solid var(--gemboss-border, #e3e3e3);
323
+ color: var(--gemboss-text-subdued, #616161);
324
+ }
325
+ .gemboss-md-rule { margin: 12px 0; border: 0; border-top: 1px solid var(--gemboss-border, #e3e3e3); }
326
+ /* Inline code: a chip, so an identifier stops reading as prose. */
327
+ .gemboss-md-code {
328
+ padding: 1px 6px;
329
+ border-radius: 6px;
330
+ background: var(--gemboss-surface-subdued, #f1f1f1);
331
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
332
+ font-size: 0.9em;
333
+ word-break: break-word;
334
+ }
335
+ /* Tables scroll INSIDE their own box. The chat lives in a 380px sidebar in the theme editor, and a
336
+ * five-column table there would otherwise push the whole panel sideways — §9.1's rule that wide
337
+ * content scrolls in its own container, not the page. */
338
+ .gemboss-md-tablewrap { margin: 0 0 12px; overflow-x: auto; }
339
+ .gemboss-md-table { border-collapse: collapse; font-size: var(--gemboss-fs-caption); min-width: 100%; }
340
+ .gemboss-md-table th,
341
+ .gemboss-md-table td {
342
+ border: 1px solid var(--gemboss-border, #e3e3e3);
343
+ padding: 6px 8px;
344
+ text-align: left;
345
+ vertical-align: top;
346
+ white-space: nowrap;
347
+ }
348
+ .gemboss-md-table th { background: var(--gemboss-surface-subdued, #f7f7f7); font-weight: 600; }
349
+ /* A picture in an answer (only from hosts markdown-blocks.ts trusts). Never wider than the bubble — the
350
+ * chat can sit in a 380px sidebar — and capped in height so five thumbnails read as a shortlist, not
351
+ * five screens. The link around it is the tap target: keyboard focus has to be visible on it. */
352
+ .gemboss-md-imglink { display: inline-block; max-width: 100%; margin: 4px 0 8px; border-radius: 8px; line-height: 0; }
353
+ .gemboss-md-imglink:focus-visible { outline: 2px solid var(--gemboss-focus, #005bd3); outline-offset: 2px; }
354
+ .gemboss-md-img {
355
+ display: block;
356
+ max-width: 100%;
357
+ max-height: 320px;
358
+ height: auto;
359
+ border: 1px solid var(--gemboss-border, #e3e3e3);
360
+ border-radius: 8px;
361
+ background: var(--gemboss-surface-subdued, #f7f7f7);
362
+ object-fit: contain;
363
+ }
364
+ /* A code block, with the fold. A 200-line answer should not push everything after it off screen. */
365
+ .gemboss-md-codeblock {
366
+ position: relative;
367
+ margin: 0 0 12px;
368
+ border: 1px solid var(--gemboss-border, #e3e3e3);
369
+ border-radius: 8px;
370
+ background: var(--gemboss-surface-subdued, #f7f7f7);
371
+ overflow: hidden;
372
+ }
373
+ .gemboss-md-codelang {
374
+ display: block;
375
+ padding: 4px 12px 0;
376
+ color: var(--gemboss-text-subdued, #8a8a8a);
377
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
378
+ font-size: 11px;
379
+ }
380
+ .gemboss-md-codeblock pre { margin: 0; padding: 8px 12px; overflow-x: auto; }
381
+ .gemboss-md-codeblock code {
382
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
383
+ font-size: 12px;
384
+ line-height: 1.5;
385
+ white-space: pre;
386
+ }
387
+ .gemboss-md-codemore {
388
+ display: block;
389
+ width: 100%;
390
+ padding: 6px 12px;
391
+ border: 0;
392
+ border-top: 1px solid var(--gemboss-border, #e3e3e3);
393
+ background: transparent;
394
+ color: var(--gemboss-text-subdued, #616161);
395
+ font-size: 12px;
396
+ text-align: left;
397
+ cursor: pointer;
398
+ }
399
+ .gemboss-md-codemore:hover { background: rgba(0, 0, 0, 0.03); }
400
+ /* ── The payload, live, in the chat ──────────────────────────────────────────────────────────────
401
+ A trace row said `apply_section_edit(section_id:"hero")` — that something happened, and nothing about
402
+ what. This is what. Open by default: folding it would put the answer back behind a click. */
403
+ .gemboss-narr-row.source { align-items: flex-start; }
404
+ .gemboss-narr-source { min-width: 0; flex: 1; }
405
+ .gemboss-narr-source-head { font-size: 11px; color: var(--gemboss-text-muted, #686b71); margin-bottom: 4px; }
406
+ .gemboss-narr-source-head code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
407
+ .gemboss-narr-source-code {
408
+ margin: 0; padding: 8px 12px; border-radius: 8px; max-height: 220px; overflow: auto;
409
+ background: var(--gemboss-primary-soft, #f2f3f5); border: 1px solid var(--gemboss-divider, #e5e7ea);
410
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--gemboss-fs-micro); line-height: 17px;
411
+ white-space: pre; tab-size: 2;
412
+ }
413
+ /* Light-ground syntax colours for the payload rows (the code-highlight.ts `c-*` spans). Contrast checked
414
+ against #fff: every one clears 4.5:1. (The page editor's Code tab shared them until it was removed, #2651.) */
415
+ .gemboss-narr-source-code .c-com { color: #6a7a86; font-style: italic; }
416
+ .gemboss-narr-source-code .c-str { color: #9a3412; }
417
+ .gemboss-narr-source-code .c-num { color: #1d6fa5; }
418
+ .gemboss-narr-source-code .c-kw { color: #7c3aed; font-weight: 600; }
419
+ .gemboss-narr-source-code .c-key { color: #0b6b3a; }
420
+ .gemboss-narr-source-code .c-tag { color: #b0197a; }
421
+ .gemboss-narr-source-code .c-attr{ color: #7c3aed; }
422
+ .gemboss-narr-source-code .c-pun { color: #6a6d73; }
423
+ @media (max-width: 520px) {
424
+ .gemboss-narr-source-code { max-height: 160px; font-size: 11px; }
425
+ }
426
+ /* A checklist row's box IS its marker; the dot column stays for alignment and shows nothing. */
427
+ .gemboss-narr-dot-quiet { visibility: hidden; }
@@ -30,6 +30,10 @@
30
30
  border: 1px solid var(--p-color-input-border, var(--gemboss-field-line));
31
31
  border-radius: var(--p-border-radius-200, 8px);
32
32
  }
33
+ /* WebKit draws `input[type=search]` as `-webkit-appearance: searchfield`: its own padding and rounding, so in Safari
34
+ (macOS and iOS, the platform the 16px rule exists for) a search box did not match the fields beside it. Same box for
35
+ every text type; the clear button stays. (GemFactory #98 review, 01/10/2026.) */
36
+ input[type="search"]:is(.gemboss-field, .gemboss-field__bare) { -webkit-appearance: none; appearance: none; }
33
37
  .gemboss-field::placeholder { color: var(--p-color-text-secondary, var(--gemboss-field-placeholder)); }
34
38
  /* A NARROW field — the inspector's T/R/B/L boxes and its number spinners are 40-68px wide, and the
35
39
  shared 12px side padding left 38px of content box for a three-digit value (measured in Chromium:
package/src/icons.tsx CHANGED
@@ -54,6 +54,7 @@ const P = {
54
54
  check: <path d="M4 10.5 8 14.5 16 5.5" />,
55
55
  plus: <path d="M10 4v12M4 10h12" />,
56
56
  close: <path d="M5 5l10 10M15 5 5 15" />,
57
+ more: <><circle cx="4.5" cy="10" r="1" /><circle cx="10" cy="10" r="1" /><circle cx="15.5" cy="10" r="1" /></>,
57
58
  utensils: <><path d="M6 3v5a2 2 0 0 0 4 0V3M8 3v14" /><path d="M15 3c-1.6 1.4-2.5 3.6-2.5 6.5H15V17" /></>,
58
59
  signOut: <path d="M8 3.5H4v13h4M12.5 6.5 16 10l-3.5 3.5M16 10H8" />,
59
60
  } satisfies Record<string, React.ReactNode>;
package/src/index.ts CHANGED
@@ -6,4 +6,5 @@ export { GemMark } from "./GemMark";
6
6
  export { AuthError, AuthField, AuthFoot, AuthForm, AuthHelp, AuthLayout, PasswordField, AuthSubmit } from "./auth";
7
7
  export { BrandLockup } from "./brand";
8
8
  export { AccountMenu, AccountMenuItem, initialsOf } from "./account";
9
+ export { Menu, MenuItem, MenuSeparator } from "./menu";
9
10
  export { Icon, ICON_NAMES, type IconName } from "./icons";
@@ -0,0 +1,110 @@
1
+ /* kit-plain.css — kit primitives that only the NON-React surfaces use (GemFactory, gemcare, gempress write the
2
+ markup by hand). They ship inside gemboss-ui.css (scripts/build-css.mjs `own`), but the admin does NOT import
3
+ this file: `ui.css` is loaded by the admin's root layout, so every rule there is paid on every admin screen,
4
+ and adding these to it pushed the admin shell 0.2 KB over its first-load budget (CI of #3591, 01/10/2026).
5
+ The admin has Polaris for menus, checkboxes and description lists. */
6
+
7
+ /* ── Icon button · Menu · Key-value list ──────────────────────────────────────
8
+ Added 01/10/2026 for GemFactory's item dialog, which had seven actions in one header row; the
9
+ secondary five now sit behind one "⋯" button. Plain CSS, same contract as the tabs and tables in ui.css.
10
+
11
+ Icon button: `<button class="ui-btn ui-btn--icon" aria-label="…"><svg…></button>` — square, the
12
+ label is REQUIRED (there is no text for a screen reader to read otherwise).
13
+
14
+ Menu: a `<details>` so it opens with no script. `<details class="ui-menu ui-menu--end">
15
+ <summary class="ui-btn ui-btn--icon" aria-label="More actions">…</summary><div class="ui-menu__list">
16
+ <button class="ui-menu__item">Copy #12<span class="ui-menu__hint">for Claude Code</span></button>
17
+ <hr class="ui-menu__sep"><button class="ui-menu__item ui-menu__item--danger">Delete…</button></div>
18
+ </details>`. `--end` aligns the list to the right edge of its button. Closing on an outside click,
19
+ on an item, and on Escape is the page's job (a few lines; GemFactory's ui-kit.mjs has them).
20
+ Items are 44px under EVERY pointer, not only coarse: they touch each other (0px apart), and §9.1
21
+ only allows a gap under 8px between targets that are already 44px.
22
+
23
+ Key-value list: `<dl class="ui-kv"><dt>Owner</dt><dd>Lily</dd>…</dl>` — label column sized to
24
+ its longest label, value column takes the rest and wraps long keys and URLs. */
25
+ .ui-btn--icon { flex: none; width: var(--ui-control-h); padding: 0; }
26
+ .ui-btn--icon.ui-btn--sm { width: 28px; }
27
+ .ui-btn--icon svg { width: 18px; height: 18px; }
28
+ /* Menu: the SURFACE is the shell's `gemboss-popover-surface` and the rows are the account menu's row (shell.css
29
+ declares `.gemboss-account-menu-item, .ui-menu__item` together), so a kit menu and the account menu on one page
30
+ look the same and a fix to one reaches the other (§9.3). Markup: `<div class="ui-menu__list gemboss-popover-surface">`.
31
+ Here: only placement, the hint, the separator and the disabled state. */
32
+ .ui-menu { position: relative; display: inline-block; }
33
+ .ui-menu > summary { list-style: none; cursor: pointer; }
34
+ .ui-menu > summary::-webkit-details-marker { display: none; }
35
+ .ui-menu__list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; display: grid; min-width: 220px; padding: var(--ui-space-1); box-sizing: border-box; }
36
+ .ui-menu--end .ui-menu__list { left: auto; right: 0; }
37
+ .ui-menu__item { display: flex; align-items: center; gap: var(--ui-space-2); white-space: nowrap; border-radius: var(--ui-radius-sm); }
38
+ .ui-menu__item:is([disabled], [aria-disabled="true"]) { opacity: .5; cursor: not-allowed; pointer-events: none; }
39
+ .ui-menu__hint { margin-left: auto; padding-left: var(--ui-space-4); color: var(--ui-text-muted); font-size: var(--ui-fs-caption); }
40
+ .ui-menu__sep { height: 1px; margin: var(--ui-space-1) 0; border: 0; background: var(--ui-border); }
41
+ /* Checkbox: the LABEL is the target; disabled turns both the label and the box to not-allowed. Focus ring in the
42
+ kit's own token (no --p-* here: this file is appended after the export's Polaris resolver runs). */
43
+ .ui-check { display: inline-flex; align-items: center; gap: var(--ui-space-2); min-height: 44px; cursor: pointer; font-size: var(--ui-fs-body); color: var(--ui-text); }
44
+ .ui-check > input { flex: none; width: 18px; height: 18px; margin: 0; accent-color: var(--ui-accent); cursor: inherit; }
45
+ .ui-check > input:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: 2px; }
46
+ .ui-check:has(> input:disabled) { opacity: .5; cursor: not-allowed; }
47
+ .ui-label { display: block; margin: 0 0 var(--ui-space-1); font-size: var(--ui-fs-caption); font-weight: 600; color: var(--ui-text-muted); }
48
+ .ui-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--ui-space-2) var(--ui-space-4); margin: 0; font-size: var(--ui-fs-body); }
49
+ .ui-kv > dt { color: var(--ui-text-muted); }
50
+ .ui-kv > dd { margin: 0; min-width: 0; overflow-wrap: anywhere; color: var(--ui-text); }
51
+
52
+ /* Under a thumb the icon-only button is 44 WIDE as well (the kit's coarse block already raises every .ui-btn to 44 tall). */
53
+ @media (pointer: coarse) { .ui-btn--icon:not(.ui-btn--sm) { min-width: 44px; } }
54
+ /* …and at phone WIDTH, as gemboss-field does: a desktop browser at 320px still reports pointer: fine. */
55
+ @media (max-width: 47.9975em) { .ui-btn--icon:not(.ui-btn--sm) { min-width: 44px; min-height: 44px; } }
56
+
57
+ /* ── Link · Media card (02/10/2026) ──────────────────────────────────────────
58
+ GemFactory, gemcare and gempress left text links in the browser's default blue-and-purple, and each drew its own
59
+ thumbnail card. The admin uses Polaris' Link and has no media card, so both live here, not in ui.css.
60
+
61
+ Link: `<a class="ui-link" href>` for a link inside text. Iris (--gemboss-accent-strong, 6.2:1 on white), underlined
62
+ so it is not told by colour alone (WCAG 1.4.1); visited looks the same (these are tools, not articles). Markdown or
63
+ other generated HTML you cannot add classes to: wrap it in `.ui-prose` and its bare `<a>` get the same look.
64
+
65
+ Media card: `<article class="ui-card ui-card--media"><a|button class="ui-card__media"><img alt=""></…>
66
+ <div class="ui-card__body">…</div><div class="ui-card__foot">…</div></article>`. The picture is 16:10 and
67
+ cropped to fill; the card has no padding of its own, the body and foot do. */
68
+ .ui-link,
69
+ .ui-prose a:not([class]) {
70
+ color: var(--gemboss-accent-strong, #3a44de);
71
+ text-decoration: underline;
72
+ text-decoration-thickness: from-font;
73
+ text-underline-offset: 2px;
74
+ }
75
+ .ui-link:visited,
76
+ .ui-prose a:not([class]):visited { color: var(--gemboss-accent-strong, #3a44de); }
77
+ .ui-link:hover,
78
+ .ui-prose a:not([class]):hover { color: var(--gemboss-accent, #5661f5); text-decoration-thickness: 2px; }
79
+ .ui-link:focus-visible,
80
+ .ui-prose a:not([class]):focus-visible { outline: 2px solid var(--ui-focus, #005bd3); outline-offset: 2px; border-radius: 2px; }
81
+
82
+ .ui-card--media { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
83
+ .ui-card__media {
84
+ display: block; width: 100%; aspect-ratio: 16 / 10; margin: 0; padding: 0; border: 0;
85
+ border-bottom: 1px solid var(--ui-border); background: var(--ui-surface-2); overflow: hidden; color: inherit;
86
+ }
87
+ .ui-card__media > img { display: block; width: 100%; height: 100%; object-fit: cover; }
88
+ button.ui-card__media, a.ui-card__media { cursor: pointer; }
89
+ .ui-card__media:focus-visible { outline: 2px solid var(--ui-focus, #005bd3); outline-offset: -2px; }
90
+ /* justify-items: start: a tag or a button in the body keeps its own width instead of stretching across the card. */
91
+ .ui-card__body { display: grid; justify-items: start; gap: var(--ui-space-2, 8px); padding: var(--ui-space-3, 12px) var(--ui-space-4, 16px); min-width: 0; }
92
+ .ui-card__foot {
93
+ display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--ui-space-2, 8px);
94
+ margin-top: auto; padding: 0 var(--ui-space-4, 16px) var(--ui-space-3, 12px);
95
+ color: var(--ui-text-muted); font-size: var(--ui-fs-caption, 13px);
96
+ }
97
+
98
+ /* Popup body and foot: `<div class="ui-modal__body">…</div><div class="ui-modal__foot"><button class="ui-btn">…</button></div>`.
99
+ The admin's Modal.tsx pads its own children, so ui.css never styled these; every non-React surface padded them by
100
+ hand (GemFactory: 16/20 and a hairline above the actions). kit-plain.js's openModal writes exactly this shape. */
101
+ .ui-modal__body { padding: 16px 20px; }
102
+ .ui-modal__foot {
103
+ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
104
+ padding: 12px 20px; border-top: 1px solid var(--ui-border);
105
+ }
106
+ /* A popup taller than the screen: the body scrolls, the head and foot stay, so the actions are always reachable
107
+ (320×568 with a long form put Huỷ/Xoá below the fold, unreachable under the locked page; review #3633). */
108
+ .ui-overlay > .ui-modal { display: flex; flex-direction: column; max-height: calc(100dvh - 40px); }
109
+ .ui-modal > .ui-modal__head, .ui-modal > .ui-modal__foot { flex: none; }
110
+ .ui-modal > .ui-modal__body { flex: 1 1 auto; min-height: 0; overflow: auto; }