@gemboss/ui 0.12.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,194 @@
1
+ /**
2
+ * Board moves (0.18.0): moving a card between the columns of a `.ui-board[data-gb-move]`, OPTIONAL (a board without it
3
+ * stays read-only). Two ways that do the same thing, so a mouse, a keyboard and a screen reader all move cards:
4
+ *
5
+ * - Drag (HTML drag and drop): pick a card (`li[data-id][draggable]`) up, the target column and the line where it will
6
+ * land are marked while it moves (`data-gb-over` on the column, `data-gb-drop="before|after"` on a card), drop.
7
+ * - Keyboard, the pattern of react-beautiful-dnd / dnd-kit: Tab to a card (`li[tabindex=0]`), Space picks it up,
8
+ * ↑ ↓ move it inside its column, ← → to the column beside, Space or Enter drops it, Escape (or leaving the card)
9
+ * puts it back. Every step is said in the board's live region (`[data-gb-live]`): "Picked up …", "Review, position
10
+ * 2 of 5", "Moved … to Review, position 2", "… stays in To do".
11
+ *
12
+ * A move is reported once, on the drop: `commit(cardId, toColumn, toIndex, fromColumn)`, `toIndex` counted in the
13
+ * target column WITHOUT the moved card (0 = top). Dropping a card where it was reports nothing. While a card is held
14
+ * nothing moves in the DOM (only attributes change), so React keeps owning the list: the React `Board` calls
15
+ * `onMove` and its parent re-renders the columns. kit-plain.js carries the same code for plain HTML and fires a
16
+ * cancelable `gb:move` event; unless the page cancels it, it moves the `li` itself. A finger cannot drag (no HTML drag
17
+ * on a phone): keep a way that is not dragging (a select or the card's ⋯ menu), as the README says.
18
+ */
19
+ export type BoardCommit = (cardId: string, toColumn: string, toIndex: number, fromColumn: string) => void;
20
+
21
+ type Held = { li: HTMLElement; id: string; from: HTMLElement; to: HTMLElement; index: number; origin: number; keys: boolean };
22
+
23
+ const colsOf = (b: HTMLElement) => Array.from(b.querySelectorAll<HTMLElement>(":scope > .ui-board__col[data-col]"));
24
+ const cardsOf = (c: HTMLElement) => Array.from(c.querySelectorAll<HTMLElement>("li[data-id]")).filter((li) => li.closest(".ui-board__col") === c);
25
+ const nameOf = (li: HTMLElement) => (li.dataset.name || li.querySelector(".ui-card__link, h2, h3, h4")?.textContent || li.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80) || "Card";
26
+ const colName = (c: HTMLElement) => (c.querySelector(".ui-board__name")?.textContent || c.dataset.col || "").trim();
27
+
28
+ export function say(board: HTMLElement, text: string): void {
29
+ let live = board.querySelector<HTMLElement>(":scope > [data-gb-live]");
30
+ if (!live) {
31
+ live = document.createElement("div");
32
+ live.className = "ui-sr-only";
33
+ live.setAttribute("aria-live", "assertive");
34
+ live.setAttribute("data-gb-live", "");
35
+ board.appendChild(live);
36
+ }
37
+ // Cleared first: the same sentence twice ("position 1 of 1") is still read.
38
+ live.textContent = "";
39
+ const l = live;
40
+ setTimeout(() => { l.textContent = text; }, 30);
41
+ }
42
+
43
+ /** Wires the board once (idempotent); `commit` is read at drop time from `board.__gbCommit`, so a re-render can swap it. */
44
+ export function attachBoardMove(board: HTMLElement & { __gbCommit?: BoardCommit; __gbMoveOn?: boolean }, commit: BoardCommit): void {
45
+ board.__gbCommit = commit;
46
+ if (board.__gbMoveOn) return;
47
+ board.__gbMoveOn = true;
48
+ let held: Held | null = null;
49
+
50
+ const others = (c: HTMLElement) => cardsOf(c).filter((li) => li !== held?.li);
51
+ const clearMarks = () => {
52
+ board.querySelectorAll("[data-gb-drop]").forEach((e) => e.removeAttribute("data-gb-drop"));
53
+ board.querySelectorAll("[data-gb-over]").forEach((e) => e.removeAttribute("data-gb-over"));
54
+ };
55
+ const mark = () => {
56
+ if (!held) return;
57
+ clearMarks();
58
+ held.to.setAttribute("data-gb-over", "");
59
+ const o = others(held.to);
60
+ const at = held.index < o.length ? o[held.index] : o[o.length - 1];
61
+ if (at) {
62
+ at.setAttribute("data-gb-drop", held.index < o.length ? "before" : "after");
63
+ // A card past "Show more" (a closed <details>) has no box: open it so the line shows.
64
+ const more = at.closest("details:not([open])") as HTMLDetailsElement | null;
65
+ if (more && board.contains(more)) more.open = true;
66
+ if (held.keys) at.scrollIntoView?.({ block: "nearest", inline: "nearest" });
67
+ }
68
+ };
69
+ const where = () => held ? `${colName(held.to)}, position ${held.index + 1} of ${others(held.to).length + 1}` : "";
70
+ const end = () => {
71
+ if (!held) return;
72
+ held.li.removeAttribute("data-gb-lifted");
73
+ board.removeAttribute("data-gb-dragging");
74
+ clearMarks();
75
+ held = null;
76
+ };
77
+ const lift = (li: HTMLElement, keys: boolean) => {
78
+ const from = li.closest<HTMLElement>(".ui-board__col");
79
+ if (!from || !li.dataset.id) return;
80
+ const origin = cardsOf(from).indexOf(li);
81
+ held = { li, id: li.dataset.id, from, to: from, index: origin, origin, keys };
82
+ // "key" or "drag": a surface may change its layout for a keyboard hold, never inside dragstart (Chrome cancels the drag).
83
+ li.setAttribute("data-gb-lifted", keys ? "key" : "drag");
84
+ if (keys) {
85
+ mark();
86
+ say(board, `Picked up ${nameOf(li)}. ${where()}. Arrow keys move it, Space drops it, Escape cancels.`);
87
+ }
88
+ };
89
+ const drop = () => {
90
+ if (!held) return;
91
+ const h = held, name = nameOf(h.li), toCol = h.to.dataset.col!, fromCol = h.from.dataset.col!;
92
+ const same = h.to === h.from && h.index === h.origin;
93
+ const at = where();
94
+ end();
95
+ if (same) { say(board, `${name} dropped where it was, in ${colName(h.from)}.`); return; }
96
+ say(board, `Moved ${name} to ${at}.`);
97
+ board.__gbCommit?.(h.id, toCol, h.index, fromCol);
98
+ if (h.keys) refocus(board, h.id);
99
+ };
100
+ const cancel = (why = "") => {
101
+ if (!held) return;
102
+ const h = held;
103
+ end();
104
+ if (h.keys) say(board, `${why}${nameOf(h.li)} stays in ${colName(h.from)}.`);
105
+ };
106
+
107
+ board.addEventListener("keydown", (e) => {
108
+ const li = (e.target as HTMLElement).closest?.<HTMLElement>("li[data-id]");
109
+ if (!li || !board.contains(li)) return;
110
+ if (!held) {
111
+ // Only the card itself picks up: a Space on its link or a button inside it is that control's.
112
+ if ((e.key === " " || e.key === "Spacebar") && e.target === li) { e.preventDefault(); lift(li, true); }
113
+ return;
114
+ }
115
+ if (held.li !== li || !held.keys) return;
116
+ const cols = colsOf(board);
117
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
118
+ e.preventDefault();
119
+ held.index = Math.max(0, Math.min(others(held.to).length, held.index + (e.key === "ArrowUp" ? -1 : 1)));
120
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
121
+ e.preventDefault();
122
+ const i = cols.indexOf(held.to) + (e.key === "ArrowLeft" ? -1 : 1);
123
+ if (i < 0 || i >= cols.length) { say(board, `${where()}. No column further ${e.key === "ArrowLeft" ? "left" : "right"}.`); return; }
124
+ held.to = cols[i];
125
+ held.index = Math.min(held.index, others(held.to).length);
126
+ } else if (e.key === " " || e.key === "Spacebar" || e.key === "Enter") {
127
+ e.preventDefault(); drop(); return;
128
+ } else if (e.key === "Escape") {
129
+ e.preventDefault(); e.stopPropagation(); cancel("Move cancelled. "); return;
130
+ } else if (e.key === "Tab") { cancel("Move cancelled. "); return; } else return;
131
+ mark();
132
+ say(board, where());
133
+ });
134
+ board.addEventListener("focusout", (e) => {
135
+ if (held?.keys && e.target === held.li && !held.li.contains(e.relatedTarget as Node | null)) cancel("Move cancelled. ");
136
+ });
137
+
138
+ board.addEventListener("dragstart", (e) => {
139
+ const li = (e.target as HTMLElement).closest?.<HTMLElement>("li[data-id]");
140
+ if (!li || !board.contains(li) || !e.dataTransfer) return;
141
+ if (held) end();
142
+ lift(li, false);
143
+ e.dataTransfer.effectAllowed = "move";
144
+ e.dataTransfer.setData("text/plain", li.dataset.id || "");
145
+ // Next frame: changing the layout inside dragstart makes Chrome cancel the drag (GemFactory found it).
146
+ requestAnimationFrame(() => { if (held) board.setAttribute("data-gb-dragging", ""); });
147
+ });
148
+ // Where a pointer event would land: the column under it and the index from the cards' midpoints. Read again on the
149
+ // drop itself, so a drag that left the board (dragleave cleared the marks) and came back still lands.
150
+ const targetAt = (e: DragEvent): { col: HTMLElement; index: number } | null => {
151
+ const col = (e.target as HTMLElement).closest?.<HTMLElement>(".ui-board__col[data-col]");
152
+ if (!col || !board.contains(col)) return null;
153
+ const o = others(col);
154
+ let index = o.length;
155
+ for (let i = 0; i < o.length; i++) {
156
+ const r = o[i].getBoundingClientRect();
157
+ if (!r.height) continue;
158
+ if (e.clientY < r.top + r.height / 2) { index = i; break; }
159
+ index = i + 1;
160
+ }
161
+ return { col, index };
162
+ };
163
+ board.addEventListener("dragover", (e) => {
164
+ if (!held || held.keys) return;
165
+ const t = targetAt(e);
166
+ if (!t) return;
167
+ e.preventDefault();
168
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
169
+ // Re-marked when the target changed OR the marks are gone (a dragleave, or Safari's null relatedTarget, cleared them).
170
+ if (t.col !== held.to || t.index !== held.index || !board.querySelector("[data-gb-over]")) { held.to = t.col; held.index = t.index; mark(); }
171
+ });
172
+ board.addEventListener("dragleave", (e) => {
173
+ if (held && !held.keys && !board.contains(e.relatedTarget as Node | null)) clearMarks();
174
+ });
175
+ board.addEventListener("drop", (e) => {
176
+ if (!held || held.keys) return;
177
+ e.preventDefault();
178
+ const t = targetAt(e);
179
+ if (!t) { cancel(); return; }
180
+ held.to = t.col; held.index = t.index;
181
+ drop();
182
+ });
183
+ board.addEventListener("dragend", () => { if (held && !held.keys) cancel(); });
184
+ }
185
+
186
+ /** After a move the card may be a new element (React remounts it in its new column): focus it again by id. */
187
+ export function refocus(board: HTMLElement, id: string): void {
188
+ const go = (n: number) => {
189
+ const li = Array.from(board.querySelectorAll<HTMLElement>("li[data-id]")).find((x) => x.dataset.id === id);
190
+ if (li && document.activeElement !== li) li.focus({ preventScroll: false });
191
+ else if (!li && n > 0) requestAnimationFrame(() => go(n - 1));
192
+ };
193
+ requestAnimationFrame(() => go(10));
194
+ }
@@ -1,110 +1 @@
1
- /*
2
- * The chat COMPOSER — @gemboss/ui (moved from apps/admin/src/components/chat/chat-composer.css, 02/10/2026).
3
- *
4
- * The toolbar row under the text box (chips lead, tools, status, the one action pinned right), the one-row
5
- * layout when there are no chips, the engine picker's layout and the Stop button. A second surface needs the
6
- * toolbar — gemcare's assistant composer is a text box and a send button, i.e. the one-row case — and a copy
7
- * would be a second owner. The box itself (`form.gemboss-ec-input`, its textarea and send button) is in chat.css.
8
- *
9
- * A SEPARATE file from chat.css on purpose, as it was a separate file in the admin: chat.css rides the shell's
10
- * first load on every screen, while this sheet follows the chat chunk — the admin imports it from ChatComposer
11
- * and EnginePicker, not from the layout (the shell sat at 239.1 / 240 KB brotli when this moved). Other surfaces
12
- * get it inside gemboss-ui.css. Rules and their order are unchanged.
13
- *
14
- * Every selector here names a composer-only class (chiplabel · chipmark · chipsub · enginepick · enginepick-btn ·
15
- * stop · toolbar · toolbar-lead · toolbar-tools), so a rule only matches while a composer is on screen.
16
- */
17
-
18
- /* The toolbar row: chips on the left, then status, then the one action button pinned right.
19
- `align-items: center` — the old row was `flex-end`, so 28px chips sat on the BOTTOM edge of a 44px
20
- button and their centres ran 8px below its centre. Measured at 1366: chips cy 819, send cy 811.
21
- The lead group is the only thing that shrinks. Send is `flex: none` and the chips inside the lead
22
- carry `min-width: 0`, so a narrow composer ellipsises a label instead of pushing send off the row. */
23
- .gemboss-ec-toolbar { flex: 1 0 100%; display: flex; align-items: center; gap: 4px; min-width: 0; }
24
- .gemboss-ec-toolbar-lead { flex: 1 1 auto; display: flex; align-items: center; gap: 4px; min-width: 0; }
25
- /* The lead GROWS, and that is what pins status + action to the right edge. With nothing in it (a
26
- browser tab has no engine picker, a driver without work mode has no select) it is removed, and
27
- whatever follows takes the auto margin instead. */
28
- .gemboss-ec-toolbar-lead:empty { display: none; }
29
- .gemboss-ec-toolbar-lead:empty + * { margin-left: auto; }
30
- /* TOOLS ARE NOT CHIPS. The 📎 lives here, never in the lead: the composer gate reads `leading` as the
31
- chip strip twice over — `edge` wants the first chip's text flush with the typed text, and `oneRow`
32
- only runs when the lead is EMPTY. A button parked in the lead pushed the chips 47px right AND left
33
- `oneRow` unable to fail at all; the gate's own negative control said so out loud
34
- ("MÙ với oneRow — đối chứng ĐÃ QUAN SÁT ĐƯỢC và vẫn xanh") once the breakage stopped registering.
35
- `flex: none` so it never steals the shrink budget the lead needs to ellipsise a long chip label. */
36
- .gemboss-ec-toolbar-tools { display: flex; align-items: center; gap: 4px; flex: none; min-width: 0; }
37
- .gemboss-ec-toolbar-tools:empty { display: none; }
38
- /* NO CHIPS ⇒ ONE ROW. Product Studio has nothing in its lead, and it still spent a whole 44px row on
39
- a lone send button beside empty space — 99-120px tall for a one-line box. The text box shares the
40
- row now and send sits at its bottom edge, where a growing message keeps it. */
41
- .gemboss-ec-input:has(> .gemboss-ec-toolbar > .gemboss-ec-toolbar-lead:empty) { flex-wrap: nowrap; align-items: flex-end; }
42
- .gemboss-ec-input:has(> .gemboss-ec-toolbar > .gemboss-ec-toolbar-lead:empty) > textarea { flex: 1 1 0; }
43
- .gemboss-ec-input:has(> .gemboss-ec-toolbar > .gemboss-ec-toolbar-lead:empty) > .gemboss-ec-toolbar { flex: 0 0 auto; }
44
- /* The engine picker is a `gemboss-ec-chipctl` like the select beside it — its colour, weight, padding
45
- and hover all come from that one rule. What is left here is layout: Popover wraps the activator in an
46
- element of its own, and the chip can only shrink if every box between it and the toolbar can.
47
- DESCENDANT, not child, for that reason — `>` matched nothing when the button sat inside Polaris'
48
- wrapper, measured, after the first attempt left it unstyled. */
49
- .gemboss-ec-enginepick { flex: 0 3 auto; min-width: 0; display: inline-flex; }
50
- .gemboss-ec-enginepick > * { min-width: 0; max-width: 100%; display: flex; }
51
- .gemboss-ec-enginepick button {
52
- min-width: 0; max-width: 100%;
53
- /* The same caret the work-mode select draws, at the same inset, so the two chips read as one type. */
54
- padding-right: 24px;
55
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
56
- background-repeat: no-repeat;
57
- background-position: right 7px center;
58
- }
59
- .gemboss-ec-chiplabel { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
60
- .gemboss-ec-chipmark { display: none; }
61
- .gemboss-ec-chipmark .gemboss-engine-mark { display: block; width: 16px; height: 16px; margin: 2px 0; }
62
- /* The steps ChatComposer measured its way down to (data-density on the form). The ellipsis above stays
63
- as the floor for an engine with no mark, never as the plan. */
64
- .gemboss-ec-input:not([data-density="full"]) .gemboss-ec-chipsub { display: none; }
65
- .gemboss-ec-input:is([data-density="icon"], [data-density="tight"]) .gemboss-ec-enginepick-btn.has-mark .gemboss-ec-chiplabel { display: none; }
66
- .gemboss-ec-input:is([data-density="icon"], [data-density="tight"]) .gemboss-ec-enginepick-btn.has-mark .gemboss-ec-chipmark { display: block; }
67
- /* A mark does not ellipsise, so once the chip IS the mark it holds its width — shrunk further it slid
68
- under the mode chip beside it. The overflow that leaves is what tells ChatComposer to step on. */
69
- .gemboss-ec-input:is([data-density="icon"], [data-density="tight"]) .gemboss-ec-enginepick:has(.has-mark) { flex-shrink: 0; }
70
- /* Open: it is showing a popover, so it stops being quiet. */
71
- .gemboss-ec-enginepick button[aria-expanded="true"] {
72
- color: var(--gemboss-text); background-color: var(--gemboss-hover, rgba(0,0,0,0.05));
73
- }
74
- @media (pointer: coarse) {
75
- /* SCOPED, and that word is load-bearing — this line was DEAD as `.gemboss-ec-stop`.
76
- A media query adds no specificity, so the unscoped rule for the same class, declared ~1800 lines
77
- LATER at its mouse size, beat this one on source order alone. Measured
78
- 2026-09-09 in a real coarse-pointer context: `matchMedia('(pointer: coarse)').matches === true`
79
- and the computed width unchanged at its mouse size. Every phone got a Stop button ten pixels
80
- under the target, while the rule meant to
81
- fix that sat here reading as done — and the source test beside this block could not see it,
82
- because a name being PRESENT is not the same fact as a rule WINNING.
83
- `.gemboss-ec-input` is the same idiom the attach button above already uses for the same reason. */
84
- .gemboss-ec-input .gemboss-ec-stop { width: 44px; height: 44px; min-width: 44px; }
85
- /* SCOPED for the reason above: this control's own look is declared later, in the shell sheet. */
86
- .gemboss-ec-input .gemboss-ec-stop-send { min-height: 44px; }
87
- .gemboss-ec-enginepick button { min-height: 44px; }
88
- }
89
- /* Sized like the send button it replaces, so the composer does not reflow when a turn starts —
90
- * a control that moves under the cursor is a control people misclick. So it is THE SAME CIRCLE AS SEND
91
- * — same 44px, same 50% radius, only the colour and the glyph change. It used to be a 32px red
92
- * rounded square beside a 44px round send: two shapes, two sizes, one slot. */
93
- .gemboss-ec-stop {
94
- flex: 0 0 auto;
95
- display: inline-flex;
96
- align-items: center;
97
- justify-content: center;
98
- width: 44px;
99
- height: 44px;
100
- min-width: 44px;
101
- border: none;
102
- border-radius: 50%;
103
- background: var(--gemboss-critical, #d72c0d);
104
- color: #fff;
105
- cursor: pointer;
106
- }
107
- .gemboss-ec-stop svg { width: 12px; height: 12px; fill: currentColor; }
108
- .gemboss-ec-stop:disabled { opacity: 0.6; cursor: default; }
109
- .gemboss-ec-stop:hover { filter: brightness(1.08); }
110
- .gemboss-ec-stop:focus-visible { outline: 2px solid var(--gemboss-focus, var(--ui-focus, #5661f5)); outline-offset: 2px; }
1
+ .gemboss-ec-toolbar{flex:1 0 100%;display:flex;align-items:center;gap:4px;min-width:0}.gemboss-ec-toolbar-lead{flex:1 1 auto;display:flex;align-items:center;gap:4px;min-width:0}.gemboss-ec-toolbar-lead:empty{display:none}.gemboss-ec-toolbar-lead:empty+*{margin-left:auto}.gemboss-ec-toolbar-tools{display:flex;align-items:center;gap:4px;flex:none;min-width:0}.gemboss-ec-toolbar-tools:empty{display:none}.gemboss-ec-input:has(>.gemboss-ec-toolbar>.gemboss-ec-toolbar-lead:empty){flex-wrap:nowrap;align-items:flex-end}.gemboss-ec-input:has(>.gemboss-ec-toolbar>.gemboss-ec-toolbar-lead:empty)>textarea{flex:1 1 0}.gemboss-ec-input:has(>.gemboss-ec-toolbar>.gemboss-ec-toolbar-lead:empty)>.gemboss-ec-toolbar{flex:0 0 auto}.gemboss-ec-enginepick{flex:0 3 auto;min-width:0;display:inline-flex}.gemboss-ec-enginepick>*{min-width:0;max-width:100%;display:flex}.gemboss-ec-enginepick button{min-width:0;max-width:100%;padding-right:24px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 7px center}.gemboss-ec-chiplabel{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gemboss-ec-chipmark{display:none}.gemboss-ec-chipmark .gemboss-engine-mark{display:block;width:16px;height:16px;margin:2px 0}.gemboss-ec-input:not([data-density=full]) .gemboss-ec-chipsub{display:none}.gemboss-ec-input:is([data-density=icon],[data-density=tight]) .gemboss-ec-enginepick-btn.has-mark .gemboss-ec-chiplabel{display:none}.gemboss-ec-input:is([data-density=icon],[data-density=tight]) .gemboss-ec-enginepick-btn.has-mark .gemboss-ec-chipmark{display:block}.gemboss-ec-input:is([data-density=icon],[data-density=tight]) .gemboss-ec-enginepick:has(.has-mark){flex-shrink:0}.gemboss-ec-enginepick button[aria-expanded=true]{color:var(--gemboss-text);background-color:var(--gemboss-hover, rgba(0,0,0,.05))}@media (pointer: coarse){.gemboss-ec-input .gemboss-ec-stop{width:44px;height:44px;min-width:44px}.gemboss-ec-input .gemboss-ec-stop-send,.gemboss-ec-enginepick button{min-height:44px}}.gemboss-ec-stop{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;min-width:44px;border:none;border-radius:50%;background:var(--gemboss-critical, #d72c0d);color:#fff;cursor:pointer}.gemboss-ec-stop svg{width:12px;height:12px;fill:currentColor}.gemboss-ec-stop:disabled{opacity:.6;cursor:default}.gemboss-ec-stop:hover{filter:brightness(1.08)}.gemboss-ec-stop:focus-visible{outline:2px solid var(--gemboss-focus, var(--ui-focus, #5661f5));outline-offset:2px}