astro-dev-edit 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/package.json +52 -0
  4. package/src/client/admin-bar.ts +622 -0
  5. package/src/client/api.ts +370 -0
  6. package/src/client/classify-cache.ts +61 -0
  7. package/src/client/css-inspect.ts +345 -0
  8. package/src/client/editors/asset-picker.ts +155 -0
  9. package/src/client/editors/body-editor.ts +419 -0
  10. package/src/client/editors/collections-panel.ts +1532 -0
  11. package/src/client/editors/copy-panel.ts +73 -0
  12. package/src/client/editors/drawer.ts +95 -0
  13. package/src/client/editors/entry.ts +433 -0
  14. package/src/client/editors/expression.ts +77 -0
  15. package/src/client/editors/fields.ts +309 -0
  16. package/src/client/editors/image.ts +268 -0
  17. package/src/client/editors/markup-insert.ts +73 -0
  18. package/src/client/editors/markup.ts +125 -0
  19. package/src/client/editors/media-grid.ts +326 -0
  20. package/src/client/editors/media-modal.ts +588 -0
  21. package/src/client/editors/notice.ts +160 -0
  22. package/src/client/editors/peek.ts +135 -0
  23. package/src/client/editors/settings-panel.ts +457 -0
  24. package/src/client/editors/source-popup.ts +166 -0
  25. package/src/client/editors/text.ts +105 -0
  26. package/src/client/editors/unsplash-pane.ts +317 -0
  27. package/src/client/element-context.ts +308 -0
  28. package/src/client/features.ts +81 -0
  29. package/src/client/focus.ts +166 -0
  30. package/src/client/group.ts +186 -0
  31. package/src/client/highlight.ts +146 -0
  32. package/src/client/hover.ts +485 -0
  33. package/src/client/icons.ts +160 -0
  34. package/src/client/markdown.ts +319 -0
  35. package/src/client/overlay.ts +466 -0
  36. package/src/client/page-source.ts +143 -0
  37. package/src/client/router.ts +198 -0
  38. package/src/client/shadow.ts +111 -0
  39. package/src/client/source-map.ts +150 -0
  40. package/src/client/state.ts +153 -0
  41. package/src/client/styles.ts +3485 -0
  42. package/src/client/tree-model.ts +45 -0
  43. package/src/client/tree.ts +366 -0
  44. package/src/client/ui.ts +987 -0
  45. package/src/client/unsplash-search.ts +250 -0
  46. package/src/index.ts +299 -0
  47. package/src/patcher/astro.ts +792 -0
  48. package/src/patcher/content-config.ts +1035 -0
  49. package/src/patcher/dotenv.ts +121 -0
  50. package/src/patcher/expression-trace.ts +326 -0
  51. package/src/patcher/frontmatter.ts +249 -0
  52. package/src/patcher/registry.ts +11 -0
  53. package/src/patcher/types.ts +32 -0
  54. package/src/server/annotate.ts +173 -0
  55. package/src/server/assets.ts +167 -0
  56. package/src/server/collection-entries.ts +91 -0
  57. package/src/server/content-config.ts +210 -0
  58. package/src/server/editor.ts +15 -0
  59. package/src/server/entry-detect.ts +110 -0
  60. package/src/server/entry-resolve-routes.ts +218 -0
  61. package/src/server/entry-routes.ts +304 -0
  62. package/src/server/inspect-locate.ts +81 -0
  63. package/src/server/inspect-routes.ts +94 -0
  64. package/src/server/middleware.ts +480 -0
  65. package/src/server/options.ts +778 -0
  66. package/src/server/page-source-routes.ts +71 -0
  67. package/src/server/paths.ts +219 -0
  68. package/src/server/private-files.ts +116 -0
  69. package/src/server/route-manifest.ts +200 -0
  70. package/src/server/router.ts +94 -0
  71. package/src/server/schema-introspect.ts +233 -0
  72. package/src/server/schema-routes.ts +808 -0
  73. package/src/server/settings-routes.ts +246 -0
  74. package/src/server/settings.ts +382 -0
  75. package/src/server/text-writes.ts +105 -0
  76. package/src/server/unsplash-routes.ts +515 -0
  77. package/src/server/zod-adapt.ts +239 -0
  78. package/src/shared/asset-path.ts +132 -0
  79. package/src/shared/protocol.ts +935 -0
  80. package/src/shared/slug.ts +17 -0
  81. package/src/shared/unsplash.ts +51 -0
@@ -0,0 +1,987 @@
1
+ /**
2
+ * Shared UI primitives for the overlay: design tokens, the element factory,
3
+ * and the generic building blocks (toast, save veil, panel, backdrop, footer
4
+ * buttons).
5
+ *
6
+ * The overlay draws inside a shadow root (shadow.ts), so host-page selectors
7
+ * cannot reach these elements at all and specificity is no longer a defense we
8
+ * have to win. Styling is moving to the single stylesheet in styles.ts; what
9
+ * stays inline here is what cannot be known until runtime — geometry measured
10
+ * off a host element, chrome insets, computed stacking layers, per-instance
11
+ * size overrides — plus anything applied to a host-page element, which never
12
+ * enters the root.
13
+ *
14
+ * The atx-* IDs and classes are internal hooks for DOM references and for that
15
+ * stylesheet. They are *not* a theming API any more: user CSS cannot match
16
+ * them across the boundary. Theming is `--atx-*` custom properties and
17
+ * `::part()` — see docs/STYLING.md.
18
+ *
19
+ * The tokens below are the single source of truth for both: styles.ts
20
+ * generates the custom-property block from them, and tests/contrast.test.ts
21
+ * holds them to WCAG AA.
22
+ */
23
+
24
+ import { mount } from './shadow.ts';
25
+
26
+ // Base layer for the overlay's *ambient* chrome — the hover outline and pill,
27
+ // the element tree, the admin bar and its menu, at Z+1..Z+4. Deliberately
28
+ // *below* Astro's dev toolbar, which pins itself at 2000000010: the toolbar is
29
+ // the source of the source annotations this whole feature reads, and none of
30
+ // this chrome claims the screen, so the toolbar stays reachable beside it.
31
+ export const Z = 1999999000;
32
+
33
+ // Base layer for every *modal* surface — backdrop, toast, panel, drawer, at
34
+ // Z_MODAL+5..Z_MODAL+10 (the deepest is the Unsplash settings panel). Above
35
+ // Astro's toolbar, because a surface that has drawn a backdrop over the page
36
+ // has claimed the whole screen: leaving it underneath let Astro's invisible
37
+ // `#dev-bar-hitbox-above` swallow clicks on whatever overlay control happened
38
+ // to land in the bottom-centre band, most visibly the entry drawer's Delete.
39
+ // The relative offsets are unchanged, so the modal stack keeps its own order.
40
+ export const Z_MODAL = 2000000020;
41
+
42
+ /**
43
+ * Design tokens, on the shadcn/ui semantic scheme.
44
+ *
45
+ * Names and roles follow shadcn's convention — a surface plus the ink that
46
+ * goes on it (`card` + `foreground`, `primary` + `primaryFg`), then `muted`,
47
+ * `destructive`, `border`, `input`, `ring` and a single `radius` knob — so the
48
+ * vocabulary is one other people already know. Three deliberate departures:
49
+ * shadcn's three interchangeable `secondary`/`muted`/`accent` surfaces are one
50
+ * `elevated` here, because the overlay only ever needs one step up from
51
+ * `card`; `mutedFg`/`faintFg` are two ink tiers where shadcn has one, because
52
+ * the hover pill and peek gutter need a quieter grey that is still legible;
53
+ * and `brand` has no shadcn counterpart at all, because shadcn's palette has
54
+ * nowhere to put a colour that means something. Dark only: this overlay paints
55
+ * over a live page and has one look, so there is no `.dark` counterpart to
56
+ * keep in step.
57
+ *
58
+ * **One colour has a job, and the rest are furniture.** `brand` marks the
59
+ * overlay pointing at *your content* — the editable outline, the hover pill,
60
+ * the save veil, a tree row aimed at a live node. Everything the tool says
61
+ * about *itself* is neutral, and its emphatic fill is a near-white `primary`.
62
+ * A purple that is also the confirm button and the active tab is a purple that
63
+ * means nothing, which is what this split exists to prevent. If a change makes
64
+ * something `brand` that is not an editable affordance, it is wrong.
65
+ *
66
+ * **Authored in OKLCH, emitted as sRGB.** The OKLCH triple in each comment is
67
+ * the source of truth — it is what makes the neutral ramp perceptually even and
68
+ * provably untinted (chroma 0, so no hue creeps into the greys). Re-derive with
69
+ * any OKLCH converter; do not hand-edit the values. Most are hex, which is what
70
+ * `hexToRgba` and `lift` need; the three that separate surfaces from each other
71
+ * are translucent white in `rgb(r g b / a)` form, so that one value composites
72
+ * correctly on every surface it can land on.
73
+ *
74
+ * **Every pairing below is contrast-verified**, and that is a constraint on
75
+ * changes, not a note about the past. Text tokens clear WCAG AA (4.5:1) against
76
+ * all three surfaces they can land on — `card`, `elevated` and `background` —
77
+ * and control outlines clear 1.4.11 non-text (3:1) against the same three.
78
+ * shadcn's own dark defaults do *not* all clear these: its `--input` is white
79
+ * at 15%, which reaches 1.6:1 on `card`. Where upstream and legibility
80
+ * disagree, legibility wins; `tests/contrast.test.ts` is what enforces it.
81
+ */
82
+ export const COLOR = {
83
+ // --- Surfaces (achromatic: chroma 0, no tint) ----------------------------
84
+ /** Deepest well — code blocks and the raw-markdown pane. oklch(0.145 0 0) */
85
+ background: '#0a0a0a',
86
+ /** The standard overlay surface: panels, drawers, popovers. oklch(0.205 0 0) */
87
+ card: '#171717',
88
+ /** Raised surface — list rows, secondary buttons, the admin bar's own
89
+ * chrome. shadcn's `secondary`/`muted`. oklch(0.269 0 0) */
90
+ elevated: '#262626',
91
+ /** The **hover** surface, one step above `elevated`. A separate token because
92
+ * hovering is a state, not a depth: a menu item at rest sits on `card` and a
93
+ * secondary button on `elevated`, and both go here when the pointer is over
94
+ * them. shadcn's `accent`. oklch(0.371 0 0) */
95
+ accent: '#404040',
96
+
97
+ // --- Lines and control edges ---------------------------------------------
98
+ /** Divider and panel edge. **Translucent white, so one value is correct on
99
+ * every surface** — a hairline is the only thing separating a panel from the
100
+ * ground it sits on, and an opaque grey can only be right on one of them.
101
+ * Separators are decorative, so this sits below the 3:1 non-text floor
102
+ * deliberately — an outline a user must *see* to operate is `input`. */
103
+ border: 'rgb(255 255 255 / 0.10)',
104
+ /**
105
+ * The material a control is made of. shadcn's `--input`, white at 15%.
106
+ *
107
+ * **A form control here has no visible border**: its boundary is the fill,
108
+ * and the border stays transparent until focus or an error paints it. That
109
+ * is the single biggest thing separating this look from a conventional dark
110
+ * form, and it is worth stating plainly because a solid outline is the
111
+ * obvious "fix" for it.
112
+ *
113
+ * The consequence is a deliberate deviation from WCAG 1.4.11, which asks 3:1
114
+ * of a control boundary: a 7.5% fill reaches ~1.5:1 on `card`. What carries
115
+ * the weight instead is `ring` — 3px, high contrast, and always painted on
116
+ * keyboard focus. Text inside a field still clears AA, which is pinned.
117
+ */
118
+ input: 'rgb(255 255 255 / 0.15)',
119
+ /** A field's resting interior — `input` at half strength, which is shadcn's
120
+ * `bg-input/50`. The small controls (checkbox, switch) use full-strength
121
+ * `input` instead: a 16px box needs more fill than a 300px one to read at
122
+ * all. */
123
+ inputBg: 'rgb(255 255 255 / 0.075)',
124
+ /**
125
+ * An outline **button's** body — `input` at 30%, shadcn's dark
126
+ * `bg-input/30`. Lighter than a field's interior, because a button is read
127
+ * at a glance and a field is read while typing in it.
128
+ *
129
+ * It looks like decoration and is not. An outline button with a transparent
130
+ * body is a hairline drawn around nothing: beside a filled confirm it reads
131
+ * as bare text rather than as the other half of the decision, which is the
132
+ * whole reason the variant exists. The edge alone cannot carry it — at these
133
+ * alphas a 1px line is the first thing the eye discards.
134
+ */
135
+ controlBg: 'rgb(255 255 255 / 0.045)',
136
+ /** Focus ring. Neutral, because focus is chrome — the overlay talking about
137
+ * itself. Clears 3:1 on every surface (3.2–4.6), which matters more here
138
+ * than it would elsewhere: with control borders transparent at rest, this
139
+ * is the indicator that has to carry the weight. oklch(0.556 0 0) */
140
+ ring: '#737373',
141
+
142
+ // --- Ink ------------------------------------------------------------------
143
+ /** Primary ink. 17.2:1 on `card`. oklch(0.985 0 0) */
144
+ foreground: '#fafafa',
145
+ /** Secondary ink: help text, hints, unknown classification — quieter than
146
+ * `foreground` while still being *read*. Worst case 5.9:1 on `elevated`.
147
+ * oklch(0.708 0 0) */
148
+ mutedFg: '#a1a1a1',
149
+ /** Tertiary ink, one step below `mutedFg`: peek line numbers, empty-state
150
+ * glyphs, the menu's status footer. Worst case 5.0:1 on `elevated` — quiet
151
+ * is not the same as unreadable. Nothing may go fainter than this.
152
+ * oklch(0.665 0 0) */
153
+ faintFg: '#949494',
154
+
155
+ // --- The loud colour ------------------------------------------------------
156
+ /**
157
+ * The overlay's one emphatic fill: the confirm button, a checked checkbox, an
158
+ * active admin-bar chip. **Near-white, and achromatic** — on a near-black
159
+ * overlay the loudest thing available is light, not hue, and spending a hue
160
+ * here is what left the old palette with a purple that meant nothing in
161
+ * particular. oklch(0.922 0 0)
162
+ */
163
+ primary: '#e5e5e5',
164
+ /** Ink on `primary` — and on `destructive`, which is also a light fill.
165
+ * 14.2:1 on `primary`. oklch(0.205 0 0) */
166
+ primaryFg: '#171717',
167
+
168
+ // --- Brand ----------------------------------------------------------------
169
+ /**
170
+ * The brand purple, with exactly one job: **this element on your page is
171
+ * editable**. The hover outline and pill, the save veil, the element tree's
172
+ * pointer at a live node, the launcher glyph.
173
+ *
174
+ * It is not a button colour, not an active-tab colour, not a selection
175
+ * colour inside the tool's own pickers. When the overlay is talking about
176
+ * itself it goes neutral; the purple is reserved for when it is pointing at
177
+ * your content, which is the only way a colour can mean anything.
178
+ * oklch(0.509 0.212 285)
179
+ */
180
+ brand: '#6144d7',
181
+ /** The brand lightened enough to read as *text* on a panel — solid `brand` is
182
+ * a background colour and fails contrast as a foreground. Links inside
183
+ * panels, and the peek gutter's tag/attr tokens. 8.2:1 on `card`.
184
+ * oklch(0.760 0.110 285) */
185
+ brandText: '#aaa7f4',
186
+
187
+ // --- Status ---------------------------------------------------------------
188
+ /**
189
+ * Danger, in one token rather than three. A light red reads as *ink* at
190
+ * 6.2:1 on `card`, as a *border* well above the 3:1 control floor, and as a
191
+ * *fill* under `primaryFg` at 6.2:1 — so the error line, the Delete button's
192
+ * outline and the failure toast are all the same colour instead of three
193
+ * reds that drift apart. oklch(0.704 0.191 22.2)
194
+ */
195
+ destructive: '#ff6467',
196
+ /** Success *background* — the saved state, the ok toast. `foreground` on it
197
+ * is 5.0:1. oklch(0.520 0.140 150) */
198
+ success: '#0a7e3a',
199
+ /** Success as text on a panel. 9.5:1 on `card`. oklch(0.780 0.150 150) */
200
+ successText: '#67d283',
201
+ /** Dynamic-content classification and warnings — a text colour, 8.8:1 on
202
+ * `card`. oklch(0.780 0.150 85) */
203
+ warning: '#e3ae28',
204
+ /** Image classification. 7.8:1 on `card`. oklch(0.720 0.150 160) */
205
+ info: '#2fc183',
206
+
207
+ // --- Categorical hues -----------------------------------------------------
208
+ /** shadcn's `chart-1..5`: five hues chosen to stay apart from each other at a
209
+ * glance, all at the same lightness so none reads as louder than the rest.
210
+ * They are what the CSS inspector's syntax theme is built from — a code
211
+ * token needs more distinguishable colours than the semantic set has, and
212
+ * reusing `brandText` for two different token kinds would erase the
213
+ * distinction the highlighter exists to draw. All clear AA on `background`,
214
+ * `card` and `elevated` (6.7:1 worst case).
215
+ * oklch(0.76–0.78 0.11–0.15 · 285/150/85/25/220) */
216
+ chart1: '#aaa7f4',
217
+ chart2: '#67d283',
218
+ chart3: '#e3ae28',
219
+ chart4: '#f98f87',
220
+ chart5: '#55c4e5',
221
+
222
+ // --- Glass -----------------------------------------------------------------
223
+ /**
224
+ * The **one place chroma is allowed in a grey**, and it is allowed for a
225
+ * reason rather than as a leftover.
226
+ *
227
+ * Three surfaces are translucent over the host page — the admin bar, its
228
+ * menu, and the element tree. A hue shift is the cue the eye uses to decide
229
+ * something is *showing through*: a tinted grey over a white page reads as
230
+ * glass because the colour is evidence of a mixture, while a perfectly
231
+ * neutral one at the same alpha and the same lightness reads as a flat scrim
232
+ * painted on top. Chroma 0 here costs the transparency the alpha is paying
233
+ * for.
234
+ *
235
+ * So these two carry chroma 0.012 at the **brand hue** — about half the cast
236
+ * the old palette had everywhere, and deliberately the brand's hue so the
237
+ * glass relates to something rather than being an arbitrary tint. Composited
238
+ * over a white page the bar lands at chroma 0.013, against 0.022 before and
239
+ * 0.000 without this.
240
+ *
241
+ * **Opaque surfaces stay achromatic.** `card`, `elevated`, `background` and
242
+ * every ink are chroma 0 and the contrast test pins them there. If a surface
243
+ * is not translucent it does not get to use these.
244
+ */
245
+ /** Bar and element tree, at the `card` lightness. oklch(0.205 0.012 285) */
246
+ glass: '#16161d',
247
+ /** The menu, one lightness step up so it separates from the bar it opens
248
+ * from — a step the flat neutral pass had collapsed. oklch(0.234 0.012 285) */
249
+ glassRaised: '#1d1d23',
250
+ } as const;
251
+
252
+ /**
253
+ * The one light surface in a dark-only overlay: the rich-text editor's page,
254
+ * which shows a Markdown body as it will look once published rather than as
255
+ * overlay chrome. Its own token group instead of `COLOR` inverted, because the
256
+ * two are not the same idea — `COLOR.foreground` is *ink*, and using it as this
257
+ * surface's background would couple a piece of paper to the colour of text.
258
+ *
259
+ * Same OKLCH-authored, contrast-verified rules as `COLOR`: body ink is 18.1:1
260
+ * on `bg` and 16.2:1 on `muted`. `link` is the brand hue darkened for paper
261
+ * (7.2:1); the overlay's own `primary` would also clear AA here at 6.3:1, but
262
+ * it is tuned to sit on a dark ground and reads thin as body-text link on
263
+ * white, so paper gets its own.
264
+ */
265
+ export const PAPER = {
266
+ /** The page itself. oklch(1 0 0) */
267
+ bg: '#ffffff',
268
+ /** Body ink. oklch(0.200 0 0) */
269
+ fg: '#161616',
270
+ /** Inset blocks — code, pre, blockquote fill. oklch(0.960 0 0) */
271
+ muted: '#f2f2f2',
272
+ /** Rules and block edges. oklch(0.880 0 0) */
273
+ border: '#d7d7d7',
274
+ /** Links, the brand hue at paper contrast. oklch(0.480 0.200 285) */
275
+ link: '#593ec7',
276
+ } as const;
277
+
278
+ /**
279
+ * Corner radii, one ladder derived from a single 10px base the way shadcn's
280
+ * `--radius` is: 0.6x, 0.8x, 1x, 1.4x.
281
+ *
282
+ * Pick by element, not by taste — the ladder only reads as one family if the
283
+ * same kind of thing always takes the same rung:
284
+ *
285
+ * - `sm` (6px) — a swatch, a tag, a checkbox
286
+ * - `md` (8px) — a small or icon button, a menu item, a tree row, a tab
287
+ * - `lg` (10px) — a form control and a full-size button; the workhorse rung
288
+ * - `xl` (14px) — a panel, a drawer, a card, a modal
289
+ * - `full` — a badge, a status chip, and a switch's track and thumb. A switch
290
+ * is on the list because a pill *is* the shape that reads as one; nothing
291
+ * else joins without the same argument
292
+ *
293
+ * A button and the field beside it share `lg`, which is what makes a row of
294
+ * mixed controls line up as one object rather than as parts.
295
+ */
296
+ export const RADIUS = {
297
+ sm: '6px',
298
+ md: '8px',
299
+ lg: '10px',
300
+ xl: '14px',
301
+ full: '999px',
302
+ } as const;
303
+
304
+ export const FONT = {
305
+ mono: 'ui-monospace, SFMono-Regular, Menlo, monospace',
306
+ ui: 'ui-sans-serif, system-ui, sans-serif',
307
+ } as const;
308
+
309
+
310
+ /**
311
+ * The surfaces exposed to user CSS as `::part()`. Deliberately small: a part is
312
+ * an API commitment, and everything expressible as a value is a custom property
313
+ * instead. Buttons, fields and rows are *not* here on purpose — add one only
314
+ * when someone needs to restructure a surface, not to recolour it.
315
+ * Documented in docs/STYLING.md.
316
+ */
317
+ const PARTS: Record<string, string> = {
318
+ 'atx-bar': 'bar',
319
+ 'atx-panel': 'panel',
320
+ 'atx-drawer': 'drawer',
321
+ 'atx-backdrop': 'backdrop',
322
+ 'atx-tooltip': 'pill',
323
+ 'atx-toast': 'toast',
324
+ };
325
+
326
+ /**
327
+ * Create an overlay element: marks it as our own UI (so the click router
328
+ * ignores it), stamps the atx-* class hook (and optional id for singletons),
329
+ * and exposes it as a `::part()` if it is one of the named surfaces.
330
+ *
331
+ * `style` is optional and is for **runtime values only** — geometry measured
332
+ * off a host element, a computed stacking layer, a per-instance size override.
333
+ * Everything static is a rule in styles.ts keyed off the class, which is what
334
+ * lets `:hover` and `:focus-visible` exist at all.
335
+ */
336
+ export function styled<K extends keyof HTMLElementTagNameMap>(
337
+ tag: K,
338
+ className: string,
339
+ style?: Partial<CSSStyleDeclaration>,
340
+ id?: string,
341
+ ): HTMLElementTagNameMap[K] {
342
+ const el = document.createElement(tag);
343
+ if (className) el.className = className;
344
+ // The first class is the element's identity ('atx-toast atx-toast-ok' → the
345
+ // toast); the modifiers after it are variants, not separate surfaces.
346
+ const part = className ? PARTS[className.split(' ')[0]] : undefined;
347
+ if (part) el.setAttribute('part', part);
348
+ if (id) el.id = id;
349
+ if (style) Object.assign(el.style, style);
350
+ return el;
351
+ }
352
+
353
+ /**
354
+ * A text-ish form control — input, textarea or select — carrying the shared
355
+ * control baseline from styles.ts.
356
+ *
357
+ * The marker is `[data-input]`, not a class, because every caller already
358
+ * names its own (`atx-field-input`, `atx-collections-input`, `atx-settings-key`
359
+ * …) and there is no shared class to key a rule off. `style` is for the
360
+ * per-caller trim a control genuinely needs — a monospace face on a path
361
+ * field, `flex` inside a row — not for the baseline, which is not repeatable
362
+ * from out here any more.
363
+ */
364
+ export function inputEl<K extends 'input' | 'textarea' | 'select'>(
365
+ tag: K,
366
+ className: string,
367
+ style?: Partial<CSSStyleDeclaration>,
368
+ ): HTMLElementTagNameMap[K] {
369
+ const el = styled(tag, className, style);
370
+ el.dataset.input = '';
371
+ return el;
372
+ }
373
+
374
+ /**
375
+ * The transparency checkerboard behind an image preview, at `size` px per
376
+ * square. One definition for the four surfaces that draw it (the image panel,
377
+ * its recent strip, the media grid tile, the asset picker), which previously
378
+ * each carried their own copy of the gradient and drifted apart in size.
379
+ *
380
+ * Built from `card` and `elevated` so the squares read as a *surface* rather
381
+ * than as content — the contrast between them is deliberately low (1.4:1),
382
+ * enough to say "this area is transparent" without competing with the image
383
+ * sitting on top of it.
384
+ */
385
+ export function CHECKER(size: number): string {
386
+ return `repeating-conic-gradient(${COLOR.elevated} 0% 25%, ${COLOR.card} 0% 50%) 50% / ${size}px ${size}px`;
387
+ }
388
+
389
+ export function basename(path: string): string {
390
+ return path.split(/[\\/]/).pop() ?? path;
391
+ }
392
+
393
+ /** Nudge a hex colour toward white for the hover state of a coloured button. */
394
+ export function lift(hex: string, by = 18): string {
395
+ const v = Number.parseInt(hex.slice(1), 16);
396
+ const channels = [(v >> 16) & 255, (v >> 8) & 255, v & 255].map((c) => Math.min(255, c + by));
397
+ return `rgb(${channels.join(', ')})`;
398
+ }
399
+
400
+ /**
401
+ * The admin bar's own chip surfaces — a button on the bar, and that button
402
+ * hovered. Here rather than in admin-bar.ts because two modules need them:
403
+ * styles.ts paints the chip, and admin-bar.ts reuses the resting one as the
404
+ * save button's `saving` background, and the two must not drift.
405
+ */
406
+ export const BAR_CHIP = 'rgba(255,255,255,0.09)';
407
+ export const BAR_CHIP_HOVER = 'rgba(255,255,255,0.20)';
408
+
409
+ export function hexToRgba(hex: string, alpha: number): string {
410
+ const n = parseInt(hex.slice(1), 16);
411
+ const r = (n >> 16) & 255;
412
+ const g = (n >> 8) & 255;
413
+ const b = n & 255;
414
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
415
+ }
416
+
417
+ // --- Viewport chrome ---------------------------------------------------------
418
+
419
+ /** Strips of the viewport that the overlay's own fixed chrome occupies. */
420
+ export interface ChromeInset {
421
+ top: number;
422
+ bottom: number;
423
+ }
424
+
425
+ let inset: ChromeInset = { top: 0, bottom: 0 };
426
+ const insetListeners = new Set<(i: ChromeInset) => void>();
427
+
428
+ /** The current chrome inset — read it when placing anything against a viewport
429
+ * edge (the hover pill, a docked panel), so it can't hide under the admin bar. */
430
+ export function chromeInset(): ChromeInset {
431
+ return inset;
432
+ }
433
+
434
+ /**
435
+ * Declare how much of the viewport edge the admin bar occupies. One-directional
436
+ * on purpose: the bar tells ui.ts, and the surfaces that must keep clear (toast,
437
+ * element tree, hover pill) read it back or subscribe — so nothing here has to
438
+ * import the bar.
439
+ */
440
+ export function setChromeInset(next: ChromeInset): void {
441
+ if (next.top === inset.top && next.bottom === inset.bottom) return;
442
+ inset = next;
443
+ for (const fn of insetListeners) fn(inset);
444
+ }
445
+
446
+ /** Subscribe to inset changes. Fires immediately with the current value, so a
447
+ * subscriber is correct whether it registers before or after the bar. */
448
+ export function onChromeInset(fn: (i: ChromeInset) => void): void {
449
+ insetListeners.add(fn);
450
+ fn(inset);
451
+ }
452
+
453
+ /** Breathing room between a highlighted element's box and the inside of the
454
+ * outline drawn around it. Flush, the border sits on the element's own edge
455
+ * and reads as part of it — worst on a heading, whose ink runs to the box. */
456
+ export const OUTLINE_GAP = 2;
457
+
458
+ /** Where a 2px outline goes to sit OUTLINE_GAP clear of `rect` on every side.
459
+ * Both figures drawn around a page element — hover's verdict box and the
460
+ * element tree's locked selection — place themselves through this, so the two
461
+ * never disagree by a pixel. The offset carries the outline's own border as
462
+ * well as the gap, because the box being sized here is a content box. */
463
+ export function outlineRect(rect: DOMRect): Partial<CSSStyleDeclaration> {
464
+ const offset = OUTLINE_GAP + 2;
465
+ return {
466
+ left: `${rect.left - offset}px`,
467
+ top: `${rect.top - offset}px`,
468
+ width: `${rect.width + OUTLINE_GAP * 2}px`,
469
+ height: `${rect.height + OUTLINE_GAP * 2}px`,
470
+ };
471
+ }
472
+
473
+ /** Lock an element during a save: dim + spinner overlay. Returns a release fn. */
474
+ export function lockElement(el: HTMLElement): () => void {
475
+ const rect = el.getBoundingClientRect();
476
+ const veil = styled('div', 'atx-veil', {
477
+ zIndex: String(Z + 3),
478
+ left: `${rect.left - 2}px`, top: `${rect.top - 2}px`,
479
+ width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
480
+ });
481
+ const chip = styled('div', 'atx-veil-chip');
482
+ chip.textContent = 'saving…';
483
+ veil.append(chip);
484
+ mount(veil);
485
+ return () => veil.remove();
486
+ }
487
+
488
+ /** Bottom-center toast, lifted clear of a bottom-docked admin bar. `kind` sets
489
+ * the accent. Auto-dismisses. */
490
+ export function toast(message: string, kind: 'ok' | 'err'): void {
491
+ const t = styled('div', `atx-toast atx-toast-${kind}`, {
492
+ zIndex: String(Z_MODAL + 5),
493
+ // The one runtime value: how far a bottom-docked admin bar pushes it up.
494
+ bottom: `${24 + inset.bottom}px`,
495
+ });
496
+ t.textContent = message;
497
+ mount(t);
498
+ requestAnimationFrame(() => t.toggleAttribute('data-shown', true));
499
+ setTimeout(() => {
500
+ t.toggleAttribute('data-shown', false);
501
+ setTimeout(() => t.remove(), 200);
502
+ }, 2400);
503
+ }
504
+
505
+ /**
506
+ * Let `el` scroll on its own, even on host pages that hijack wheel events.
507
+ *
508
+ * Smooth-scroll libraries (Lenis, Locomotive, GSAP ScrollSmoother) listen for
509
+ * `wheel` on window with `{passive:false}` and `preventDefault()` it, driving
510
+ * the page from their own animation loop. A nested overflow container then
511
+ * never scrolls natively — the page slides under our open panel instead. Every
512
+ * scrollable surface in the overlay goes through this.
513
+ *
514
+ * Three layers, cheapest first:
515
+ * - `overscroll-behavior: contain` stops scroll *chaining* to the page when
516
+ * this element is already at its top or bottom. Useful even without a
517
+ * smooth-scroll library.
518
+ * - the `data-*-prevent` attributes are the documented opt-outs those
519
+ * libraries look for on the event target's ancestors (Lenis resolves them
520
+ * with `closest()`, so a token `<span>` deep inside still matches). Inert
521
+ * on pages that don't use them.
522
+ * - stopping propagation keeps the event from reaching a window-level
523
+ * listener at all, which also covers hand-rolled implementations. The
524
+ * listener stays passive — it never calls `preventDefault`, so the browser's
525
+ * own scrolling of this element is untouched.
526
+ */
527
+ export function isolateScroll(el: HTMLElement): void {
528
+ el.style.overscrollBehavior = 'contain';
529
+ el.setAttribute('data-lenis-prevent', ''); // Lenis
530
+ el.setAttribute('data-scroll-ignore', ''); // Locomotive Scroll
531
+ el.addEventListener('wheel', stopScrollPropagation, { passive: true });
532
+ el.addEventListener('touchmove', stopScrollPropagation, { passive: true });
533
+ }
534
+
535
+ function stopScrollPropagation(e: Event): void {
536
+ e.stopPropagation();
537
+ }
538
+
539
+ /**
540
+ * A centered modal panel shell with a title bar, body slot, and footer slot.
541
+ * `action` is placed at the right of the title bar — a jump-to-editor button,
542
+ * for panels whose title names a source location. It arrives built rather than
543
+ * described because icons.ts imports this module, and reaching back for
544
+ * `icon()` here would close a cycle.
545
+ *
546
+ * `opts` covers the panels that need a different size or stacking layer (the
547
+ * media modal, the widened image panel). Sizing stays here rather than being
548
+ * poked into `panel.style` by callers, so one module owns panel chrome.
549
+ */
550
+ export interface PanelOptions {
551
+ /** CSS width; defaults to `min(420px, 92vw)`. */
552
+ width?: string;
553
+ /** CSS height. Omitted means auto — the panel is as tall as its content.
554
+ * Setting it makes the body the scrolling region. */
555
+ height?: string;
556
+ /** Offset added to the base `Z_MODAL`. Defaults to 6 (the standard panel
557
+ * layer); the media modal uses 8 so it can stack above the CMS drawer. */
558
+ layer?: number;
559
+ }
560
+ export function buildPanel(
561
+ title: string,
562
+ action?: HTMLElement,
563
+ opts: PanelOptions = {},
564
+ ): HTMLElement {
565
+ const panel = styled('div', 'atx-panel', {
566
+ zIndex: String(Z_MODAL + (opts.layer ?? 6)),
567
+ ...(opts.width ? { width: opts.width } : {}),
568
+ ...(opts.height ? { height: opts.height } : {}),
569
+ });
570
+ // A sized panel lays its title/body/foot out as a column so the body is the
571
+ // only part that grows, and its body becomes the scrolling region. Both are
572
+ // layout, so the flag is what crosses into CSS, not the declarations.
573
+ if (opts.height) panel.dataset.sized = '';
574
+
575
+ const bar = styled('div', 'atx-panel-title');
576
+ const heading = styled('span', 'atx-panel-heading');
577
+ heading.textContent = title;
578
+ bar.append(heading);
579
+ if (action) bar.append(action);
580
+
581
+ const body = styled('div', 'atx-panel-body');
582
+ body.dataset.body = '';
583
+ isolateScroll(body);
584
+
585
+ const foot = styled('div', 'atx-panel-foot');
586
+ foot.dataset.foot = '';
587
+
588
+ panel.append(bar, body, foot);
589
+ return panel;
590
+ }
591
+
592
+ /** A right-side drawer shell (entry editor, settings): title bar with an action
593
+ * slot, scrollable body, sticky footer. Same [data-body]/[data-foot] contract
594
+ * as buildPanel, so wirePanelButtons works unchanged. */
595
+ export interface DrawerOptions {
596
+ /** CSS width; defaults to `min(max(440px, 50vw), 94vw)`. */
597
+ width?: string;
598
+ /**
599
+ * A second line under the title, in muted ink — which file is open, which
600
+ * collection it belongs to. The title says what the drawer is; this says
601
+ * which one, which is the half that used to be crammed after a `·`.
602
+ */
603
+ description?: string;
604
+ /** Offset added to the base `Z_MODAL`. Defaults to 6, the standard panel
605
+ * layer. The settings drawer can open *above* the media modal (which sits at
606
+ * 8), so it needs to ask for a higher one. */
607
+ layer?: number;
608
+ /**
609
+ * A chip beside the name, qualifying the drawer as a whole — "experimental".
610
+ * It belongs on the title rather than in the body because it is true of every
611
+ * view the drawer navigates to, and the title is the one thing that survives
612
+ * that navigation. Build it with {@link badge}.
613
+ */
614
+ badge?: HTMLElement;
615
+ }
616
+ export function buildDrawer(title: string, opts: DrawerOptions = {}): HTMLElement {
617
+ const drawer = styled('div', 'atx-drawer', {
618
+ zIndex: String(Z_MODAL + (opts.layer ?? 6)),
619
+ ...(opts.width ? { width: opts.width } : {}),
620
+ });
621
+
622
+ // A card header, laid out by the stylesheet's grid: name, optional second
623
+ // line, and an action corner that spans both rows. `[data-actions]` is only
624
+ // set when a caller fills the slot — see `wireDrawerAction`.
625
+ const bar = styled('div', 'atx-drawer-title');
626
+ // The name and anything qualifying it share a flex row: the title ellipsizes
627
+ // when the drawer is narrow and a badge beside it must not be what gets cut.
628
+ const barName = styled('div', 'atx-drawer-name');
629
+ const barText = styled('span', 'atx-drawer-title-text');
630
+ barText.textContent = title;
631
+ barName.append(barText);
632
+ if (opts.badge) barName.append(opts.badge);
633
+ bar.append(barName);
634
+ if (opts.description) {
635
+ const sub = styled('span', 'atx-drawer-subtitle');
636
+ sub.textContent = opts.description;
637
+ sub.title = opts.description;
638
+ bar.append(sub);
639
+ }
640
+ const barActions = styled('span', 'atx-drawer-actions');
641
+ barActions.dataset.actions = '';
642
+ bar.append(barActions);
643
+
644
+ const body = styled('div', 'atx-drawer-body');
645
+ body.dataset.body = '';
646
+ isolateScroll(body);
647
+
648
+ const foot = styled('div', 'atx-drawer-foot');
649
+ foot.dataset.foot = '';
650
+
651
+ drawer.append(bar, body, foot);
652
+ return drawer;
653
+ }
654
+
655
+ /** Which voice a badge speaks in: `warn` for something the reader has to weigh
656
+ * before acting, `muted` for a state that is merely worth saying. */
657
+ export type BadgeTone = 'warn' | 'muted';
658
+
659
+ export interface SwitchControl {
660
+ /** The label + track, as one clickable unit. */
661
+ root: HTMLElement;
662
+ /** The underlying checkbox, for `checked` and `disabled`. */
663
+ input: HTMLInputElement;
664
+ }
665
+
666
+ /**
667
+ * A switch: a named capability that is on or off right now.
668
+ *
669
+ * **Not a checkbox, and the difference is the sentence each one completes.** A
670
+ * checkbox answers a question about the thing being edited — *is this field
671
+ * required?* — and takes effect when the form is saved. A switch flips
672
+ * something that is live, and it reads as its own label: *Content editor, on*.
673
+ * Reach for it when the control **is** the setting, and for a checkbox when the
674
+ * control is one answer inside a form.
675
+ *
676
+ * Built on a real `input[type=checkbox]` with `role="switch"` rather than a
677
+ * `<button>` carrying state in JS: `appearance: none` takes the painting and
678
+ * leaves every keyboard and assistive behaviour where it was — Space toggles,
679
+ * the label click works because a `<label>` wraps both halves, and `:checked`
680
+ * does the styling with no state to keep in step. `role="switch"` is what makes
681
+ * a screen reader say "switch, on" instead of "checkbox, checked".
682
+ *
683
+ * Geometry is shadcn's, measured off the live reference rather than remembered:
684
+ * a 32×18 track, a 16px thumb inset 1px, and 14px of travel.
685
+ */
686
+ export function switchControl(
687
+ label: string,
688
+ checked: boolean,
689
+ onChange: (on: boolean) => void,
690
+ /**
691
+ * A fuller accessible name, for a switch whose visible label repeats down a
692
+ * list ("Content editor" on every collection row). It **must contain the
693
+ * visible label** — a speech user says what they see, so a name that replaced
694
+ * it rather than extending it would leave the control unreachable by voice
695
+ * (WCAG 2.5.3). Omit it wherever the visible label is already unique.
696
+ */
697
+ accessibleName?: string,
698
+ ): SwitchControl {
699
+ const root = styled('label', 'atx-switch-row');
700
+ const text = styled('span', 'atx-switch-label');
701
+ text.textContent = label;
702
+ // `styled`, not `inputEl`: the shared control baseline carries `min-height:
703
+ // 32px`, which no width or height here could win against. Every other
704
+ // checkbox in the overlay opts out the same way.
705
+ const input = styled('input', 'atx-switch');
706
+ input.type = 'checkbox';
707
+ input.role = 'switch';
708
+ input.checked = checked;
709
+ // Without this the wrapping <label> is the whole name, which is right until
710
+ // the same words appear on every row of a list.
711
+ if (accessibleName) input.ariaLabel = accessibleName;
712
+ input.addEventListener('change', () => onChange(input.checked));
713
+ root.append(text, input);
714
+ return { root, input };
715
+ }
716
+
717
+ /**
718
+ * A short chip qualifying whatever it sits beside — "experimental", "draft",
719
+ * "not registered". One word or two, radius `full`, which it shares only with a
720
+ * switch's track.
721
+ *
722
+ * It is an outline rather than a fill on purpose: a filled chip beside a title
723
+ * competes with it for the eye, and a badge is a footnote to the name, not a
724
+ * second name.
725
+ */
726
+ export function badge(label: string, tone: BadgeTone): HTMLElement {
727
+ const el = styled('span', 'atx-badge');
728
+ el.dataset.tone = tone;
729
+ el.textContent = label;
730
+ return el;
731
+ }
732
+
733
+ /**
734
+ * A tab strip and the single host its active pane is mounted into.
735
+ *
736
+ * Lifted out of the media modal, which grew the first one for its
737
+ * Project/Unsplash sources, when the Settings drawer needed a second. The
738
+ * behaviours worth keeping are both non-obvious:
739
+ *
740
+ * - **The strip is not rendered when there is only one tab.** A lone tab is not
741
+ * a choice, and drawing it implies there are others.
742
+ * - **`onActivate` fires once per tab, the first time it is shown.** Panes whose
743
+ * setup costs something (a network search) should not pay it until the user
744
+ * asks for them, and should not pay it twice.
745
+ *
746
+ * Selected state is carried by `aria-selected` alone: styles.ts paints from
747
+ * that attribute, so the thing a screen reader reads and the thing the eye
748
+ * reads are the same fact rather than two that can disagree.
749
+ */
750
+ export interface TabSpec {
751
+ /** Stable id; also the `atx-<prefix>-tab-<id>` class suffix. */
752
+ id: string;
753
+ label: string;
754
+ /** Shown in {@link TabStrip.host} while this tab is active. */
755
+ pane: HTMLElement;
756
+ }
757
+
758
+ export interface TabStrip {
759
+ /** The row of tab buttons. Empty when there is only one tab. */
760
+ strip: HTMLElement;
761
+ /** Where the active pane lives. Mount it wherever the content belongs. */
762
+ host: HTMLElement;
763
+ /** Switch tabs. A no-op for an unknown id or the current one. */
764
+ show(id: string): void;
765
+ activeId(): string;
766
+ /** Retitle a tab in place — for a label that carries a live count. No-op
767
+ * when the strip is unrendered (a single tab). */
768
+ setLabel(id: string, text: string): void;
769
+ }
770
+
771
+ export interface TabsOptions {
772
+ /** `atx-<prefix>-tabs` / `atx-<prefix>-tab` class stem. Defaults to `tabs`. */
773
+ classPrefix?: string;
774
+ /** Once per tab, the first time it becomes active. */
775
+ onActivate?(id: string): void;
776
+ /** Every time the active tab changes, after the swap. */
777
+ onChange?(id: string): void;
778
+ }
779
+
780
+ export function buildTabs(tabs: readonly TabSpec[], opts: TabsOptions = {}): TabStrip {
781
+ const prefix = opts.classPrefix ?? 'tabs';
782
+ const strip = styled('div', `atx-${prefix}-tabs`);
783
+ strip.role = 'tablist';
784
+ const host = styled('div', `atx-${prefix}-host`);
785
+ host.dataset.tabhost = '';
786
+
787
+ const buttons = new Map<string, HTMLButtonElement>();
788
+ const activated = new Set<string>();
789
+ let active = tabs[0];
790
+
791
+ const paint = (): void => {
792
+ for (const [id, btn] of buttons) {
793
+ btn.setAttribute('aria-selected', id === active?.id ? 'true' : 'false');
794
+ }
795
+ };
796
+
797
+ const show = (id: string): void => {
798
+ const next = tabs.find((t) => t.id === id);
799
+ if (!next || next === active) return;
800
+ active = next;
801
+ host.textContent = '';
802
+ host.append(next.pane);
803
+ paint();
804
+ if (!activated.has(id)) {
805
+ activated.add(id);
806
+ opts.onActivate?.(id);
807
+ }
808
+ opts.onChange?.(id);
809
+ };
810
+
811
+ // Only render the strip when there is a choice to make.
812
+ if (tabs.length > 1) {
813
+ for (const { id, label } of tabs) {
814
+ const btn = styled('button', `atx-${prefix}-tab atx-${prefix}-tab-${id}`);
815
+ btn.type = 'button';
816
+ btn.role = 'tab';
817
+ btn.textContent = label;
818
+ btn.addEventListener('click', () => show(id));
819
+ buttons.set(id, btn);
820
+ strip.append(btn);
821
+ }
822
+ paint();
823
+ }
824
+
825
+ if (active) {
826
+ host.append(active.pane);
827
+ // The first tab is active from the start, so it counts as activated without
828
+ // firing the callback — its caller has already built it.
829
+ activated.add(active.id);
830
+ }
831
+
832
+ return {
833
+ strip,
834
+ host,
835
+ show,
836
+ activeId: () => active?.id ?? '',
837
+ setLabel: (id, text) => {
838
+ const btn = buttons.get(id);
839
+ if (btn) btn.textContent = text;
840
+ },
841
+ };
842
+ }
843
+
844
+ /**
845
+ * Point an `<img>` at a file that was *just* written, retrying briefly.
846
+ *
847
+ * Vite's static middleware 404s a newly written file for a short window — long
848
+ * enough that the load fired the instant an upload or import returns will fail,
849
+ * leaving an empty box even though the same URL serves fine a moment later
850
+ * (verified: 404 at write time, 200 immediately after). The browser also caches
851
+ * that 404 for the life of the page, so each attempt carries a fresh query
852
+ * string to defeat both.
853
+ *
854
+ * Display only — the value written into source is always the clean path.
855
+ */
856
+ export function setFreshSrc(img: HTMLImageElement, path: string): void {
857
+ const ATTEMPTS = 8;
858
+ const DELAY_MS = 200;
859
+ let left = ATTEMPTS;
860
+ const bust = (): string => `${path}${path.includes('?') ? '&' : '?'}atx=${Date.now()}`;
861
+ const onError = (): void => {
862
+ if (--left <= 0) {
863
+ img.removeEventListener('error', onError);
864
+ return;
865
+ }
866
+ setTimeout(() => {
867
+ img.src = bust();
868
+ }, DELAY_MS);
869
+ };
870
+ img.addEventListener('error', onError);
871
+ img.addEventListener('load', () => img.removeEventListener('error', onError), { once: true });
872
+ img.src = bust();
873
+ }
874
+
875
+ /** Dim backdrop that closes the panel when clicked. `layer` matches the panel
876
+ * it sits under — the media modal's backdrop must land above the CMS drawer it
877
+ * can open over, not at the standard panel layer. */
878
+ export function buildBackdrop(onClose: () => void, layer = 5): HTMLElement {
879
+ const b = styled('div', 'atx-backdrop', { zIndex: String(Z_MODAL + layer) });
880
+ b.addEventListener('click', onClose);
881
+ return b;
882
+ }
883
+
884
+ /**
885
+ * Button variants, named as shadcn names them: `default` (the filled brand
886
+ * action), `secondary` (a filled step up from the surface), `outline`, `ghost`
887
+ * and `destructive`.
888
+ *
889
+ * One deliberate departure: shadcn's `destructive` is *filled* red. Here it is
890
+ * an outline, because a footer that puts a filled red button beside the filled
891
+ * brand button reads as two equally-weighted calls to action when only one of
892
+ * them is the thing the user came to do.
893
+ */
894
+ export type ButtonKind = 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive';
895
+
896
+ /** A panel/drawer footer button. The single source of button styling — each
897
+ * kind is `.atx-btn-<kind>` in styles.ts, so a variant is a class rather than
898
+ * a lookup table copied onto the element. */
899
+ export function footButton(label: string, kind: ButtonKind, onClick: () => void): HTMLButtonElement {
900
+ const btn = styled('button', `atx-btn atx-btn-${kind}`);
901
+ btn.type = 'button';
902
+ btn.textContent = label;
903
+ btn.addEventListener('click', onClick);
904
+ return btn;
905
+ }
906
+
907
+ /**
908
+ * Enable or disable a button *visibly*: a disabled primary button would
909
+ * otherwise look identical to a live one and read as broken rather than as
910
+ * unavailable. Every caller that sets `.disabled` on an overlay button should
911
+ * go through this instead.
912
+ *
913
+ * The dimming is `[data-dimmed]` in styles.ts rather than `:disabled`, because
914
+ * the two are not the same set — several places set `.disabled` directly and
915
+ * have never dimmed. Widening that is a design decision, not a side effect of
916
+ * moving a value into a stylesheet.
917
+ */
918
+ export function setButtonEnabled(btn: HTMLButtonElement, enabled: boolean): void {
919
+ btn.disabled = !enabled;
920
+ btn.toggleAttribute('data-dimmed', !enabled);
921
+ }
922
+
923
+ /**
924
+ * A small translucent button for the dark hover pill and its rules card — the
925
+ * pill's "open" / "copy" and each rule's own "open". One primitive so the three
926
+ * stay identical; `extra` covers the per-caller trim (font size, flex).
927
+ *
928
+ * The label always lives in its own `[data-label]` span so `setPillLabel` can
929
+ * swap the text without disturbing the icon (icons come from icons.ts, passed
930
+ * in as an element — ui.ts stays the leaf module nothing else here imports).
931
+ */
932
+ export function pillButton(
933
+ className: string,
934
+ label: string,
935
+ title: string,
936
+ extra: Partial<CSSStyleDeclaration> = {},
937
+ iconEl?: HTMLElement,
938
+ ): HTMLButtonElement {
939
+ const btn = styled('button', className, extra);
940
+ // The caller names the class, so there is no shared one for the stylesheet
941
+ // to match; this attribute is the pill's identity instead.
942
+ btn.dataset.pill = '';
943
+ btn.type = 'button';
944
+ /**
945
+ * `.atx-pill-label` lifts the text 1.5px — optical centring, which flexbox
946
+ * can't do for text. `align-items: center` lines up the *boxes*, but a text
947
+ * box is asymmetric around its ink: on an 11px label it reserves ~11px above
948
+ * the baseline for ascenders and 2px below, while an all-lowercase word
949
+ * ("open", "copy") only paints the ~6px x-height band. Centred by box, that
950
+ * band lands ~1.5px below the middle of the pill and the label reads as
951
+ * sitting low. The icon, whose glyph does fill its box, needs no correction.
952
+ *
953
+ * Offset rather than margin (a margin would be half-absorbed by the centring
954
+ * it is correcting) and `relative` rather than a transform (the spinner icon
955
+ * animates the host's own transform).
956
+ */
957
+ const text = styled('span', 'atx-pill-label');
958
+ text.dataset.label = '';
959
+ text.textContent = label;
960
+ if (iconEl) btn.append(iconEl);
961
+ btn.append(text);
962
+ btn.title = title;
963
+ return btn;
964
+ }
965
+
966
+ /** Retarget a pill button's label (its icon, if any, stays put). */
967
+ export function setPillLabel(btn: HTMLButtonElement, label: string): void {
968
+ const text = btn.querySelector<HTMLElement>('[data-label]');
969
+ if (text) text.textContent = label;
970
+ else btn.textContent = label;
971
+ }
972
+
973
+ /** Populate a panel's footer with cancel + confirm buttons, and optionally a
974
+ * secondary (outline) button between them for a second action. */
975
+ export function wirePanelButtons(
976
+ panel: HTMLElement,
977
+ onCancel: () => void,
978
+ onConfirm: () => void,
979
+ opts: { confirmLabel?: string; secondaryLabel?: string; onSecondary?: () => void } = {},
980
+ ): void {
981
+ const foot = panel.querySelector('[data-foot]') as HTMLElement;
982
+ foot.append(footButton('Cancel', 'outline', onCancel));
983
+ if (opts.secondaryLabel && opts.onSecondary) {
984
+ foot.append(footButton(opts.secondaryLabel, 'outline', opts.onSecondary));
985
+ }
986
+ foot.append(footButton(opts.confirmLabel ?? 'Save', 'default', onConfirm));
987
+ }