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,622 @@
1
+ import { has } from './features.ts';
2
+ import { type IconName, icon, setIcon } from './icons.ts';
3
+ import * as state from './state.ts';
4
+ import { BAR_CHIP, BAR_CHIP_HOVER, COLOR, lift, setChromeInset, styled } from './ui.ts';
5
+ import { overlayActiveElement } from './shadow.ts';
6
+
7
+ /**
8
+ * The admin bar — the overlay's one piece of persistent chrome.
9
+ *
10
+ * A full-width strip docked to the top (or bottom) of the viewport holding every
11
+ * global action: the element tree, edit mode, the CMS entry drawer, the bar's own
12
+ * pin/dock controls, and the exit button that doubles as the save indicator.
13
+ * Modelled on the WordPress admin bar with three deliberate differences:
14
+ *
15
+ * - it **overlays** the page rather than pushing it down. The top edge belongs
16
+ * to the host site (sticky headers live there), so reflowing it would change
17
+ * the very layout the user is editing;
18
+ * - it is **translucent at rest** and opaque on approach, so it reads as a tool
19
+ * over the page instead of part of it;
20
+ * - it can be **unpinned** — retracting off-screen until the pointer reaches
21
+ * that edge, leaving only a hairline — or **docked to the bottom** when the
22
+ * top is where the page's own chrome lives. Both persist across reloads.
23
+ *
24
+ * Items come from a registry: `register()` takes a spec and the bar renders it,
25
+ * either inline or in the overflow menu behind the brand mark. Adding an action
26
+ * is one entry, not more layout code — and `refresh()` re-evaluates every item's
27
+ * label/icon/visibility/lit-state from the live app state, so specs stay
28
+ * declarative.
29
+ *
30
+ * What the bar does NOT own: edit mode, the tree, and the entry drawer all live
31
+ * elsewhere and are reached through `AdminBarDeps`. The bar is a view.
32
+ */
33
+
34
+ /** Which viewport edge the bar is docked to. */
35
+ export type BarEdge = 'top' | 'bottom';
36
+
37
+ /** A control on the bar (or in its overflow menu). Everything dynamic is a
38
+ * getter, evaluated on every `refresh()`. */
39
+ export interface BarItemSpec {
40
+ /** DOM id, and the registry key. `atx-`-prefixed by convention. */
41
+ id: string;
42
+ label: string | (() => string);
43
+ icon: IconName | (() => IconName);
44
+ /** Tooltip; defaults to the label. */
45
+ title?: string | (() => string);
46
+ /** Inline in the bar (default) or behind the overflow menu. */
47
+ place?: 'bar' | 'menu';
48
+ /** Which end of the bar. Ignored for menu items. */
49
+ side?: 'left' | 'right';
50
+ /** Icon only — the label becomes the tooltip. Bar items only. */
51
+ compact?: boolean;
52
+ /** Per-item style trim. */
53
+ extra?: Partial<CSSStyleDeclaration>;
54
+ /** Hidden entirely when this returns false. */
55
+ visible?(): boolean;
56
+ /** Rendered lit (accent background) when this returns true. */
57
+ active?(): boolean;
58
+ /** Rendered unclickable when this returns true. */
59
+ disabled?(): boolean;
60
+ /** Last word on appearance — used by the exit button, whose colour is the
61
+ * save state. Runs after the standard paint; return the resting/hover
62
+ * backgrounds to keep the hover behaviour in step. */
63
+ paint?(btn: HTMLButtonElement): { bg: string; bgHover: string } | void;
64
+ onSelect(): void;
65
+ }
66
+
67
+ export interface AdminBarDeps {
68
+ isEditMode(): boolean;
69
+ /** Turn edit mode on. */
70
+ enterEdit(): void;
71
+ /** Leave edit mode — committing anything pending first, never discarding. */
72
+ exitEdit(): void;
73
+ /** Show the element tree (turning edit mode on if it is off). */
74
+ showTree(): void;
75
+ /** Hide the element tree, staying in edit mode. */
76
+ hideTree(): void;
77
+ isTreeOpen(): boolean;
78
+ /** Whether this page declares a backing content entry. */
79
+ hasEntry(): boolean;
80
+ openEntry(): void;
81
+ /** Open the file this page is written in, in the user's editor. */
82
+ openPageSource(): void;
83
+ /** Open the collection and field designer. */
84
+ openCollections(): void;
85
+ /** Open the integration settings drawer. Injected because admin-bar.ts
86
+ * imports nothing from `editors/`. */
87
+ openSettings(): void;
88
+ }
89
+
90
+ export interface AdminBarHandle {
91
+ /** Nodes for the composition root to append at boot. */
92
+ elements: HTMLElement[];
93
+ /** Add an item. Rendered immediately, in registration order. */
94
+ register(item: BarItemSpec): void;
95
+ /** Re-evaluate every item's label, icon, visibility and lit state. */
96
+ refresh(): void;
97
+ /** Recompute whether the bar is out or retracted. For changes the bar can't
98
+ * see coming from a pointer event — edit mode turning on or off. */
99
+ syncVisibility(): void;
100
+ /** The right-aligned note (hold-to-navigate); null hides it. */
101
+ setHint(text: string | null): void;
102
+ }
103
+
104
+ const BAR_H = 36;
105
+ /** How close to the docked edge the pointer must get to reveal an unpinned bar. */
106
+ const HOT_ZONE = 4;
107
+ /** Grace period before an unpinned bar slides away again. */
108
+ const RETRACT_DELAY = 350;
109
+ /** The save button's label while writing — reads as busy without dropping below
110
+ * AA on the button's own hover background (white at 0.20 over the bar). */
111
+
112
+ const PHASE_LABEL: Record<state.SavePhase, string> = {
113
+ clean: 'Done',
114
+ dirty: 'Save & exit',
115
+ saving: 'Saving…',
116
+ saved: 'Saved',
117
+ error: 'Save failed',
118
+ };
119
+ const PHASE_ICON: Record<state.SavePhase, IconName> = {
120
+ clean: 'check',
121
+ dirty: 'dot',
122
+ saving: 'spinner',
123
+ saved: 'check',
124
+ error: 'alert',
125
+ };
126
+ const PHASE_TITLE: Record<state.SavePhase, string> = {
127
+ clean: 'Everything is written to disk — leave edit mode',
128
+ dirty: 'Save your change, then leave edit mode',
129
+ saving: 'Writing to the file…',
130
+ saved: 'Written to disk',
131
+ error: 'The last save failed and the change was rolled back — click to leave edit mode',
132
+ };
133
+ /**
134
+ * The save chip paints itself, so it carries its own ink as well as its own
135
+ * fill: `success` is a dark green that needs light ink, while `primary` and
136
+ * `destructive` are both *light* fills that need dark ink. One shared
137
+ * foreground across all five would be illegible on two of them.
138
+ */
139
+ const PHASE_PAINT: Record<state.SavePhase, { bg: string; ink: string }> = {
140
+ clean: { bg: COLOR.success, ink: COLOR.foreground },
141
+ dirty: { bg: COLOR.primary, ink: COLOR.primaryFg },
142
+ saving: { bg: BAR_CHIP, ink: COLOR.mutedFg },
143
+ saved: { bg: COLOR.success, ink: COLOR.foreground },
144
+ error: { bg: COLOR.destructive, ink: COLOR.primaryFg },
145
+ };
146
+
147
+ // --- Persisted preferences ---------------------------------------------------
148
+
149
+ interface BarPrefs {
150
+ edge: BarEdge;
151
+ pinned: boolean;
152
+ }
153
+
154
+ const PREFS_KEY = 'astroDevEditBar';
155
+ const DEFAULT_PREFS: BarPrefs = { edge: 'top', pinned: true };
156
+
157
+ function loadPrefs(): BarPrefs {
158
+ try {
159
+ const raw = localStorage.getItem(PREFS_KEY);
160
+ if (!raw) return { ...DEFAULT_PREFS };
161
+ const parsed = JSON.parse(raw) as Partial<BarPrefs>;
162
+ return {
163
+ edge: parsed.edge === 'bottom' ? 'bottom' : 'top',
164
+ pinned: parsed.pinned !== false,
165
+ };
166
+ } catch {
167
+ // No localStorage (or junk in it) — the defaults are fine.
168
+ return { ...DEFAULT_PREFS };
169
+ }
170
+ }
171
+
172
+ function savePrefs(prefs: BarPrefs): void {
173
+ try {
174
+ localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
175
+ } catch {
176
+ // Preferences just won't persist.
177
+ }
178
+ }
179
+
180
+ // --- Build -------------------------------------------------------------------
181
+
182
+ interface BarNode {
183
+ spec: BarItemSpec;
184
+ btn: HTMLButtonElement;
185
+ ico: HTMLElement;
186
+ label: HTMLElement | null;
187
+ bg: string;
188
+ bgHover: string;
189
+ /** Whether `spec.paint` supplied this node's colours — the only nodes whose
190
+ * background is written from JS rather than matched in styles.ts. */
191
+ painted: boolean;
192
+ }
193
+
194
+ export function initAdminBar(deps: AdminBarDeps): AdminBarHandle {
195
+ const prefs = loadPrefs();
196
+ let overBar = false;
197
+ let retractTimer: number | null = null;
198
+
199
+ // Z+4: the top of the ambient chrome — above the element tree (Z+3) and the
200
+ // hover pill, below every modal surface (Z_MODAL+5 and up, a separate base
201
+ // that clears Astro's toolbar) — an open drawer covers the bar, as it should.
202
+ const bar = styled('div', 'atx-bar', undefined, 'atx-bar');
203
+
204
+ // What an unpinned bar leaves behind: a 3px accent line with a wider nub in
205
+ // the middle, so the bar is discoverable once it has slid away.
206
+ const hairline = styled('div', 'atx-hairline', undefined, 'atx-hairline');
207
+ const nub = styled('div', 'atx-hairline-nub');
208
+ hairline.append(nub);
209
+
210
+ const leftGroup = styled('div', 'atx-bar-group');
211
+ const rightGroup = styled('div', 'atx-bar-group atx-bar-group-right');
212
+
213
+ // The brand mark is also the overflow menu's anchor: as the item registry
214
+ // grows past the width of the bar, items land in the menu instead of
215
+ // squeezing the row.
216
+ const brand = styled('button', 'atx-bar-brand', undefined, 'atx-bar-brand');
217
+ brand.type = 'button';
218
+ brand.title = 'astro-dev-edit — menu';
219
+ brand.setAttribute('aria-haspopup', 'menu');
220
+ brand.append(icon('cursor', 16));
221
+
222
+ const separator = styled('div', 'atx-bar-sep');
223
+
224
+ const hint = styled('span', 'atx-bar-hint', undefined, 'atx-bar-hint');
225
+
226
+ leftGroup.append(brand, separator);
227
+ rightGroup.append(hint);
228
+ bar.append(leftGroup, rightGroup);
229
+
230
+ const menu = styled('div', 'atx-menu', undefined, 'atx-menu');
231
+ menu.setAttribute('role', 'menu');
232
+ const menuItems = styled('div', 'atx-menu-items');
233
+ const menuFoot = styled('div', 'atx-menu-foot');
234
+ const liveDot = styled('span', 'atx-menu-live');
235
+ menuFoot.append(liveDot);
236
+ menuFoot.append(document.createTextNode('dev server connected'));
237
+ menu.append(menuItems, menuFoot);
238
+
239
+ // --- Visibility ----------------------------------------------------------
240
+
241
+ function inHotZone(y: number): boolean {
242
+ return prefs.edge === 'top' ? y <= HOT_ZONE : y >= window.innerHeight - HOT_ZONE;
243
+ }
244
+
245
+ function menuOpen(): boolean {
246
+ return menu.hasAttribute('data-on');
247
+ }
248
+
249
+ /** The one place the bar's opacity/transform is decided. Pinned: it stays put
250
+ * and only fades in. Unpinned: it slides off the edge entirely — except while
251
+ * edit mode is on, when it behaves as pinned no matter the preference. In edit
252
+ * mode the bar carries the save state and the way out, so it must never be
253
+ * off-screen; unpinning is about keeping it out of the way while you browse. */
254
+ function applyVisibility(approached: boolean): void {
255
+ // overlayActiveElement, not document.activeElement: the latter reports the
256
+ // shadow host for anything focused in here, so the bar would dim while you
257
+ // were typing in it.
258
+ const open = approached || overBar || menuOpen() || bar.contains(overlayActiveElement());
259
+ // Both flags, then let styles.ts decide. Docked means "never retracts", and
260
+ // the resting opacity, the slide-off, the dead pointer-events on a bar that
261
+ // is off-screen and the hairline all follow from the pair rather than from
262
+ // five writes split across two branches here.
263
+ const docked = prefs.pinned || deps.isEditMode();
264
+ bar.toggleAttribute('data-docked', docked);
265
+ bar.toggleAttribute('data-open', open);
266
+ hairline.toggleAttribute('data-on', !docked && !open);
267
+ }
268
+
269
+ function reveal(): void {
270
+ if (retractTimer !== null) {
271
+ clearTimeout(retractTimer);
272
+ retractTimer = null;
273
+ }
274
+ applyVisibility(true);
275
+ }
276
+
277
+ function scheduleRetract(): void {
278
+ if (retractTimer !== null) clearTimeout(retractTimer);
279
+ retractTimer = window.setTimeout(() => {
280
+ retractTimer = null;
281
+ applyVisibility(false);
282
+ }, RETRACT_DELAY);
283
+ }
284
+
285
+ bar.addEventListener('mouseenter', () => {
286
+ overBar = true;
287
+ reveal();
288
+ });
289
+ bar.addEventListener('mouseleave', () => {
290
+ overBar = false;
291
+ scheduleRetract();
292
+ });
293
+ bar.addEventListener('focusin', reveal);
294
+ bar.addEventListener('focusout', scheduleRetract);
295
+ // Pointer position drives the reveal rather than an invisible hot-zone
296
+ // element, which would sit over the page and eat clicks on the host site.
297
+ // Only *transitions* in and out of the zone do anything — a plain mousemove
298
+ // across the page must not churn timers on every event.
299
+ let inZone = false;
300
+ document.addEventListener(
301
+ 'pointermove',
302
+ (e) => {
303
+ const next = inHotZone(e.clientY);
304
+ if (next === inZone) return;
305
+ inZone = next;
306
+ if (next) reveal();
307
+ else if (!overBar) scheduleRetract();
308
+ },
309
+ { passive: true },
310
+ );
311
+
312
+ /** Everything positional reads the docked edge — and the bar's strip is
313
+ * reserved whether it is pinned or not. Retraction is transient: the tree
314
+ * must not reflow every time the bar slides in, and the bar must never sit
315
+ * on top of it. */
316
+ function applyEdge(): void {
317
+ // One flag on each of the two fixed elements; the nub follows its parent.
318
+ // Which edge a border, a shadow and a corner belong to is layout, and
319
+ // saying it twice in JS is how the two used to drift.
320
+ bar.dataset.edge = prefs.edge;
321
+ hairline.dataset.edge = prefs.edge;
322
+ setChromeInset({
323
+ top: prefs.edge === 'top' ? BAR_H : 0,
324
+ bottom: prefs.edge === 'bottom' ? BAR_H : 0,
325
+ });
326
+ }
327
+
328
+ /** Edit mode changed: the bar is out for the whole of it, and free to retract
329
+ * again once it ends. */
330
+ function syncVisibility(): void {
331
+ reveal();
332
+ if (!prefs.pinned && !deps.isEditMode()) scheduleRetract();
333
+ }
334
+
335
+ function setPinned(on: boolean): void {
336
+ prefs.pinned = on;
337
+ savePrefs(prefs);
338
+ applyVisibility(on);
339
+ refresh();
340
+ }
341
+
342
+ function setEdge(next: BarEdge): void {
343
+ prefs.edge = next;
344
+ savePrefs(prefs);
345
+ applyEdge();
346
+ closeMenu();
347
+ reveal(); // a flip always shows itself once, wherever it landed
348
+ if (!prefs.pinned) scheduleRetract();
349
+ refresh();
350
+ }
351
+
352
+ // --- Overflow menu -------------------------------------------------------
353
+
354
+ function openMenu(): void {
355
+ menu.toggleAttribute('data-on', true);
356
+ const anchor = brand.getBoundingClientRect();
357
+ menu.style.left = `${Math.max(6, anchor.left)}px`;
358
+ if (prefs.edge === 'top') {
359
+ menu.style.top = `${anchor.bottom + 7}px`;
360
+ menu.style.bottom = '';
361
+ } else {
362
+ menu.style.bottom = `${window.innerHeight - anchor.top + 7}px`;
363
+ menu.style.top = '';
364
+ }
365
+ brand.setAttribute('aria-expanded', 'true');
366
+ reveal(); // the bar can't retract while its own menu is open
367
+ }
368
+
369
+ function closeMenu(): void {
370
+ menu.toggleAttribute('data-on', false);
371
+ brand.setAttribute('aria-expanded', 'false');
372
+ }
373
+
374
+ brand.addEventListener('click', (e) => {
375
+ e.stopPropagation();
376
+ if (menuOpen()) closeMenu();
377
+ else {
378
+ refresh();
379
+ openMenu();
380
+ }
381
+ });
382
+ document.addEventListener('click', (e) => {
383
+ if (menuOpen() && !e.composedPath().includes(menu)) closeMenu();
384
+ });
385
+ // Capture phase + stopPropagation so an open menu consumes the Escape rather
386
+ // than also clearing the tree selection behind it.
387
+ document.addEventListener(
388
+ 'keydown',
389
+ (e) => {
390
+ if (e.key !== 'Escape' || !menuOpen()) return;
391
+ e.stopPropagation();
392
+ closeMenu();
393
+ },
394
+ true,
395
+ );
396
+ closeMenu();
397
+
398
+ // --- Item registry -------------------------------------------------------
399
+
400
+ const nodes: BarNode[] = [];
401
+
402
+ function paintNode(node: BarNode): void {
403
+ const { spec, btn, ico, label } = node;
404
+ const visible = spec.visible ? spec.visible() : true;
405
+ btn.toggleAttribute('data-hidden', !visible);
406
+ if (!visible) return;
407
+
408
+ const text = typeof spec.label === 'function' ? spec.label() : spec.label;
409
+ if (label) label.textContent = text;
410
+ setIcon(ico, typeof spec.icon === 'function' ? spec.icon() : spec.icon, spec.place === 'menu' ? 15 : 14);
411
+ const title = typeof spec.title === 'function' ? spec.title() : spec.title;
412
+ btn.title = title ?? text;
413
+ btn.setAttribute('aria-label', text);
414
+
415
+ btn.toggleAttribute('data-active', spec.active?.() ?? false);
416
+ const off = spec.disabled?.() ?? false;
417
+ btn.disabled = off;
418
+ btn.toggleAttribute('data-off', off);
419
+ // `paint` is a caller-supplied hook returning arbitrary colours — the save
420
+ // button runs its phase through it — so a painted node keeps the JS hover
421
+ // path and its own inline background. Every other node hovers in CSS.
422
+ const painted = spec.paint?.(btn);
423
+ node.painted = !!painted;
424
+ if (painted) {
425
+ node.bg = painted.bg;
426
+ node.bgHover = painted.bgHover;
427
+ }
428
+ }
429
+
430
+ function build(spec: BarItemSpec): BarNode {
431
+ const inMenu = spec.place === 'menu';
432
+ const btn = styled(
433
+ 'button',
434
+ inMenu ? 'atx-menu-item' : `atx-bar-btn${spec.compact ? ' atx-bar-btn-icon' : ''}`,
435
+ // Only the caller's own trim; the box is .atx-menu-item / .atx-bar-btn,
436
+ // and a compact button is the -icon modifier rather than two ternaries.
437
+ spec.extra,
438
+ spec.id,
439
+ );
440
+ btn.type = 'button';
441
+ const ico = icon(typeof spec.icon === 'function' ? spec.icon() : spec.icon, inMenu ? 15 : 14);
442
+ btn.append(ico);
443
+ let label: HTMLElement | null = null;
444
+ if (!spec.compact) {
445
+ label = styled('span', 'atx-bar-btn-label');
446
+ btn.append(label);
447
+ }
448
+ const node: BarNode = { spec, btn, ico, label, bg: BAR_CHIP, bgHover: BAR_CHIP_HOVER, painted: false };
449
+ btn.addEventListener('mouseenter', () => {
450
+ if (node.painted && !btn.disabled) btn.style.background = node.bgHover;
451
+ });
452
+ btn.addEventListener('mouseleave', () => {
453
+ if (node.painted) btn.style.background = node.bg;
454
+ });
455
+ btn.addEventListener('click', (e) => {
456
+ e.stopPropagation();
457
+ if (inMenu) closeMenu();
458
+ spec.onSelect();
459
+ });
460
+ return node;
461
+ }
462
+
463
+ function register(spec: BarItemSpec): void {
464
+ const node = build(spec);
465
+ nodes.push(node);
466
+ if (spec.place === 'menu') menuItems.append(node.btn);
467
+ else if (spec.side === 'right') rightGroup.append(node.btn);
468
+ else leftGroup.append(node.btn);
469
+ paintNode(node);
470
+ }
471
+
472
+ function refresh(): void {
473
+ for (const node of nodes) paintNode(node);
474
+ }
475
+
476
+ // --- Core items ----------------------------------------------------------
477
+
478
+ // Elements comes first: it is how you find what you can edit, and the tree's
479
+ // row hover only means anything in edit mode — so it turns edit mode on with
480
+ // it rather than sitting there half-inert.
481
+ register({
482
+ id: 'atx-bar-elements',
483
+ label: 'Elements',
484
+ // Same glyph as the tree's own edge tab (#atx-tree-tab): both open the
485
+ // panel, so they should read as one control from either end.
486
+ icon: 'sidebar',
487
+ title: 'Show the element tree for this page',
488
+ active: () => deps.isTreeOpen(),
489
+ onSelect: () => (deps.isTreeOpen() ? deps.hideTree() : deps.showTree()),
490
+ });
491
+
492
+ register({
493
+ id: 'atx-toggle',
494
+ label: () => (deps.isEditMode() ? 'Editing' : 'Edit page'),
495
+ icon: 'pencil',
496
+ title: () =>
497
+ deps.isEditMode() ? 'Leave edit mode (saving anything pending)' : 'Click text and images on the page to edit them',
498
+ active: () => deps.isEditMode(),
499
+ onSelect: () => (deps.isEditMode() ? deps.exitEdit() : deps.enterEdit()),
500
+ });
501
+
502
+ register({
503
+ id: 'atx-entry',
504
+ label: 'Edit entry',
505
+ icon: 'file',
506
+ title: 'Edit this page’s content entry',
507
+ visible: () => deps.hasEntry(),
508
+ onSelect: () => deps.openEntry(),
509
+ });
510
+
511
+ register({
512
+ id: 'atx-bar-pin',
513
+ label: 'Pin the bar',
514
+ icon: () => (prefs.pinned ? 'pin' : 'pinOff'),
515
+ // Lit means pinned: the button shows the state you are IN, not the one it
516
+ // would take you to. Unpinned mid-edit says so, since the bar visibly
517
+ // ignores the setting until you leave edit mode.
518
+ title: () =>
519
+ prefs.pinned
520
+ ? 'Pinned — click to hide until the pointer nears this edge'
521
+ : deps.isEditMode()
522
+ ? 'Unpinned — but the bar stays out while you are editing'
523
+ : 'Pin the bar open',
524
+ side: 'right',
525
+ compact: true,
526
+ active: () => prefs.pinned,
527
+ onSelect: () => setPinned(!prefs.pinned),
528
+ });
529
+
530
+ register({
531
+ id: 'atx-bar-edge',
532
+ label: 'Move the bar',
533
+ icon: () => (prefs.edge === 'top' ? 'panelBottom' : 'panelTop'),
534
+ title: () =>
535
+ prefs.edge === 'top' ? 'Move the bar to the bottom' : 'Move the bar back to the top',
536
+ side: 'right',
537
+ compact: true,
538
+ onSelect: () => setEdge(prefs.edge === 'top' ? 'bottom' : 'top'),
539
+ });
540
+
541
+ // The exit control. Its label and colour ARE the save indicator — green only
542
+ // once everything is on disk — so leaving edit mode can never be mistaken for
543
+ // discarding work, and never silently discards it either.
544
+ register({
545
+ id: 'atx-bar-exit',
546
+ label: () => PHASE_LABEL[state.savePhase()],
547
+ icon: () => PHASE_ICON[state.savePhase()],
548
+ title: () => PHASE_TITLE[state.savePhase()],
549
+ side: 'right',
550
+ extra: { minWidth: '96px' },
551
+ visible: () => deps.isEditMode(),
552
+ // Deliberately NOT disabled while the write is in flight. Pressing it is
553
+ // what *starts* that write: the pointer going down blurs the inline edit,
554
+ // which commits, so by mouse-up the phase is already 'saving' — and a
555
+ // disabled button swallows the click that was going to ask for the exit.
556
+ // The user pressed a button labelled "Save & exit" and got the save only.
557
+ // Staying live costs nothing, because exitEditing() already knows how to
558
+ // wait for an in-flight write before leaving.
559
+ paint: (btn) => {
560
+ const phase = state.savePhase();
561
+ const { bg, ink } = PHASE_PAINT[phase];
562
+ btn.style.background = bg;
563
+ btn.style.color = ink;
564
+ btn.style.cursor = phase === 'saving' ? 'progress' : 'pointer';
565
+ return { bg, bgHover: bg.startsWith('#') ? lift(bg) : bg };
566
+ },
567
+ onSelect: () => deps.exitEdit(),
568
+ });
569
+
570
+ register({
571
+ id: 'atx-menu-page-source',
572
+ place: 'menu',
573
+ label: 'Open page source',
574
+ icon: 'code',
575
+ title: 'Open the file this page is written in, in your editor',
576
+ // The whole item is a launch-my-editor action, so it goes when that is off.
577
+ visible: () => has('openInEditor'),
578
+ onSelect: () => deps.openPageSource(),
579
+ });
580
+
581
+ // Collections is a *peer* of Settings, not a tab inside it: a collection's
582
+ // shape is the project's own committed source, while an option is a switch on
583
+ // this tool. Reaching the designer should not mean going through settings.
584
+ register({
585
+ id: 'atx-menu-collections',
586
+ place: 'menu',
587
+ label: 'Collections',
588
+ icon: 'collections',
589
+ title: 'Design your content collections — fields, types and entries',
590
+ // The whole designer sits behind the entry editor server-side, so the item
591
+ // goes when that is off rather than opening a drawer that can only refuse.
592
+ visible: () => has('entryEditor'),
593
+ onSelect: () => deps.openCollections(),
594
+ });
595
+
596
+ register({
597
+ id: 'atx-menu-settings',
598
+ place: 'menu',
599
+ label: 'Settings',
600
+ icon: 'settings',
601
+ title: 'Integration settings — every option, editable here',
602
+ onSelect: () => deps.openSettings(),
603
+ });
604
+
605
+ // The exit button's whole point is to track the save state, so the bar
606
+ // repaints on every phase change.
607
+ state.onSavePhase(() => refresh());
608
+
609
+ applyEdge();
610
+ applyVisibility(false);
611
+
612
+ return {
613
+ elements: [hairline, bar, menu],
614
+ register,
615
+ refresh,
616
+ syncVisibility,
617
+ setHint(text: string | null): void {
618
+ hint.textContent = text ?? '';
619
+ hint.toggleAttribute('data-on', !!text);
620
+ },
621
+ };
622
+ }