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.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- package/src/shared/unsplash.ts +51 -0
package/src/client/ui.ts
ADDED
|
@@ -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
|
+
}
|