@commonpub/layer 0.23.3 โ†’ 0.25.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/README.md +41 -12
  2. package/components/LayoutRow.vue +944 -0
  3. package/components/LayoutSection.vue +1028 -0
  4. package/components/LayoutSlot.vue +104 -162
  5. package/components/PageFrame.vue +116 -0
  6. package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
  7. package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
  8. package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
  9. package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
  10. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
  11. package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
  13. package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
  14. package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
  15. package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
  16. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
  17. package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
  18. package/components/blocks/BlockDividerView.vue +52 -2
  19. package/components/homepage/ContentGridSection.vue +23 -1
  20. package/components/homepage/HeroSection.vue +69 -8
  21. package/components/sections/SectionCta.vue +175 -0
  22. package/components/sections/SectionLearning.vue +232 -0
  23. package/composables/autoFormSchema.ts +319 -0
  24. package/composables/useAdminSidebar.ts +116 -0
  25. package/composables/useEditorChrome.ts +56 -0
  26. package/composables/useFeatures.ts +32 -5
  27. package/composables/useLayout.ts +46 -43
  28. package/composables/useLayoutAnnouncer.ts +332 -0
  29. package/composables/useLayoutAutoSave.ts +117 -0
  30. package/composables/useLayoutDrag.ts +290 -0
  31. package/composables/useLayoutEditor.ts +593 -0
  32. package/composables/useLayoutHistory.ts +583 -0
  33. package/composables/useLayoutHotkeys.ts +366 -0
  34. package/composables/useLayoutResize.ts +783 -0
  35. package/layouts/admin.vue +137 -24
  36. package/middleware/admin-layouts.ts +29 -0
  37. package/nuxt.config.ts +14 -0
  38. package/package.json +8 -5
  39. package/pages/[...customPath].vue +154 -0
  40. package/pages/admin/homepage.vue +46 -0
  41. package/pages/admin/index.vue +16 -0
  42. package/pages/admin/layouts/[id].vue +1110 -0
  43. package/pages/admin/layouts/index.vue +356 -0
  44. package/pages/explore.vue +16 -6
  45. package/sections/builtin/content-feed.ts +18 -29
  46. package/sections/builtin/contests.ts +30 -0
  47. package/sections/builtin/cta.ts +46 -0
  48. package/sections/builtin/custom-html.ts +36 -0
  49. package/sections/builtin/divider.ts +15 -17
  50. package/sections/builtin/editorial.ts +29 -0
  51. package/sections/builtin/embed.ts +31 -0
  52. package/sections/builtin/gallery.ts +29 -0
  53. package/sections/builtin/heading.ts +14 -19
  54. package/sections/builtin/hero.ts +16 -51
  55. package/sections/builtin/hubs.ts +30 -0
  56. package/sections/builtin/image.ts +12 -49
  57. package/sections/builtin/learning.ts +30 -0
  58. package/sections/builtin/markdown.ts +29 -0
  59. package/sections/builtin/paragraph.ts +14 -17
  60. package/sections/builtin/stats.ts +35 -0
  61. package/sections/builtin/video.ts +30 -0
  62. package/sections/registry.ts +38 -7
  63. package/server/api/admin/homepage/sections.put.ts +52 -1
  64. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  65. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  66. package/server/api/admin/layouts/[id].delete.ts +33 -1
  67. package/server/api/admin/layouts/[id].put.ts +78 -0
  68. package/server/api/admin/layouts/index.post.ts +60 -4
  69. package/server/api/admin/layouts/migrate-homepage.post.ts +68 -0
  70. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  71. package/server/api/layouts/by-route.get.ts +64 -12
  72. package/server/plugins/feature-flags-prime.ts +39 -0
  73. package/server/utils/layoutCache.ts +37 -1
  74. package/server/utils/validateSectionConfigs.ts +123 -0
  75. package/theme/base.css +1 -0
  76. package/components/sections/SectionContentFeed.vue +0 -160
  77. package/components/sections/SectionDivider.vue +0 -55
  78. package/components/sections/SectionHeading.vue +0 -78
  79. package/components/sections/SectionHero.vue +0 -164
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
@@ -0,0 +1,332 @@
1
+ /**
2
+ * useLayoutAnnouncer โ€” singleton screen-reader narration channel for
3
+ * the layout editor's drag/drop + Move Up/Down operations.
4
+ *
5
+ * Phase 3b/A. The @vue-dnd-kit/core package ships NO ARIA live region
6
+ * out of the box (verified at session 162 close โ€” grep of the package
7
+ * source returned no `aria-live` / `role="status"` rules); WCAG 2.1.1
8
+ * Level A requires drag-drop to be reachable + narrated for keyboard +
9
+ * screen-reader users. This composable + its <AdminLayoutsAnnouncer>
10
+ * companion close that gap.
11
+ *
12
+ * Singleton design: a module-level ref. Every `useLayoutAnnouncer()`
13
+ * call gets the same `message` ref + the same `announce()` function.
14
+ * <LayoutSection> and <LayoutRow> call `announce()` on drag/drop
15
+ * events; the <AdminLayoutsAnnouncer> component reads `message` and
16
+ * mirrors it into a `role="status" aria-live="assertive"` div.
17
+ *
18
+ * Why a singleton (vs provide/inject)? The composable is consumed in
19
+ * deep children of the editor page; provide/inject would require
20
+ * provider plumbing across every component tree. A module-scoped ref
21
+ * has the same effective lifetime (the editor page mounts โ†’ unmounts;
22
+ * the next editor mount resets via clearAnnouncement). Mirrors how
23
+ * `useTheme` + `useEditorChrome` are written elsewhere in the layer.
24
+ *
25
+ * Narration discipline (per feedback-visual-editor-ux-patterns):
26
+ * - position-based ('moved to position 3 of 5'), NEVER index-based
27
+ * ('moved to index 2'). Users count from 1; arrays count from 0.
28
+ * - `assertive` not `polite` for drag pickup + drop (the user needs
29
+ * to know NOW, otherwise the next arrow press lands silently).
30
+ * - announce on END not START to avoid double-narration when the
31
+ * user picks up + immediately drops in the same place.
32
+ * - clear the message after a short delay so the live region doesn't
33
+ * re-announce on re-focus or reflow (screen readers don't repeat
34
+ * unchanged content, but explicit clearing is more robust).
35
+ */
36
+ import { ref, type Ref } from 'vue';
37
+
38
+ /* ------------------------------------------------------------------ */
39
+ /* Two narration channels โ€” assertive (drag/drop) + polite (undo/redo) */
40
+ /* ------------------------------------------------------------------ */
41
+ /*
42
+ * Drag/drop state changes are TIME-CRITICAL: the user's next keypress
43
+ * lands on whichever drop target is currently under the cursor; missing
44
+ * the previous narration means flying blind on the next step. That
45
+ * earns `aria-live="assertive"`.
46
+ *
47
+ * Undo / redo confirmations are INFORMATIONAL: the user is telling the
48
+ * editor what to do, the editor is acknowledging. Interrupting another
49
+ * narration (e.g. a save status) to announce "Undid: โ€ฆ" is louder than
50
+ * the action warrants. That earns `aria-live="polite"` โ€” the screen
51
+ * reader queues it for the next quiet moment.
52
+ *
53
+ * Mixing both channels into one assertive region would push undo
54
+ * confirmations to interrupt drag narration, which is the opposite of
55
+ * what we want. So two separate refs + a separate role="status"
56
+ * polite mirror in <AdminLayoutsAnnouncer>.
57
+ */
58
+
59
+ const message = ref<string>('');
60
+ const politeMessage = ref<string>('');
61
+
62
+ /** Auto-clear handles so stale messages don't linger for the next
63
+ * announcement (when the new message would be identical to the
64
+ * lingering one, some screen readers don't re-announce). One handle
65
+ * per channel so the timers don't clobber each other. */
66
+ let clearHandle: ReturnType<typeof setTimeout> | null = null;
67
+ let politeClearHandle: ReturnType<typeof setTimeout> | null = null;
68
+ const CLEAR_AFTER_MS = 1200;
69
+
70
+ function scheduleClear(): void {
71
+ if (typeof window === 'undefined') return;
72
+ if (clearHandle !== null) clearTimeout(clearHandle);
73
+ clearHandle = setTimeout(() => {
74
+ message.value = '';
75
+ clearHandle = null;
76
+ }, CLEAR_AFTER_MS);
77
+ }
78
+
79
+ function schedulePoliteClear(): void {
80
+ if (typeof window === 'undefined') return;
81
+ if (politeClearHandle !== null) clearTimeout(politeClearHandle);
82
+ politeClearHandle = setTimeout(() => {
83
+ politeMessage.value = '';
84
+ politeClearHandle = null;
85
+ }, CLEAR_AFTER_MS);
86
+ }
87
+
88
+ export interface LayoutAnnouncer {
89
+ /** The current ASSERTIVE narration text โ€” drag/drop state changes.
90
+ * Bound to the announcer component's aria-live="assertive" region. */
91
+ message: Ref<string>;
92
+ /** The current POLITE narration text โ€” undo/redo + non-time-critical
93
+ * confirmations. Bound to the announcer component's separate
94
+ * aria-live="polite" region. */
95
+ politeMessage: Ref<string>;
96
+ /** Set the assertive message + schedule an auto-clear. Calling
97
+ * announce() twice in quick succession REPLACES the previous
98
+ * message (last-write-wins) โ€” the user only cares about the
99
+ * most-recent state. */
100
+ announce: (text: string) => void;
101
+ /** Set the polite message โ€” for undo/redo confirmations + other
102
+ * informational acknowledgements. Same last-write-wins semantics. */
103
+ announcePolite: (text: string) => void;
104
+ /** Immediately clear BOTH channels. Called on editor unmount to
105
+ * prevent the message lingering into the next page. */
106
+ clear: () => void;
107
+ }
108
+
109
+ export function useLayoutAnnouncer(): LayoutAnnouncer {
110
+ function announce(text: string): void {
111
+ // Same-text re-announcement: nudge by setting to empty first so
112
+ // screen readers DO re-announce (some skip otherwise). Done in a
113
+ // micro-task so Vue batches the empty + final assignment into one
114
+ // reactive update.
115
+ if (message.value === text) {
116
+ message.value = '';
117
+ Promise.resolve().then(() => { message.value = text; });
118
+ } else {
119
+ message.value = text;
120
+ }
121
+ scheduleClear();
122
+ }
123
+ function announcePolite(text: string): void {
124
+ if (politeMessage.value === text) {
125
+ politeMessage.value = '';
126
+ Promise.resolve().then(() => { politeMessage.value = text; });
127
+ } else {
128
+ politeMessage.value = text;
129
+ }
130
+ schedulePoliteClear();
131
+ }
132
+ function clear(): void {
133
+ message.value = '';
134
+ politeMessage.value = '';
135
+ if (clearHandle !== null) {
136
+ clearTimeout(clearHandle);
137
+ clearHandle = null;
138
+ }
139
+ if (politeClearHandle !== null) {
140
+ clearTimeout(politeClearHandle);
141
+ politeClearHandle = null;
142
+ }
143
+ }
144
+ return { message, politeMessage, announce, announcePolite, clear };
145
+ }
146
+
147
+ /* ------------------------------------------------------------------ */
148
+ /* Pure narration helpers โ€” formatted positions for keyboard ops + */
149
+ /* drag/drop outcomes. Position-based wording per the a11y memory. */
150
+ /* ------------------------------------------------------------------ */
151
+
152
+ /** "position 3 of 5" โ€” 1-indexed user-facing position. */
153
+ export function formatPosition(index: number, total: number): string {
154
+ return `position ${index + 1} of ${total}`;
155
+ }
156
+
157
+ /** Announcement for a section insertion (palette โ†’ row drop OR a brand
158
+ * new section programmatically added). */
159
+ export function narrateInserted(sectionType: string, at: number, total: number): string {
160
+ return `${sectionType} inserted at ${formatPosition(at, total)}.`;
161
+ }
162
+
163
+ /** Announcement for a within-row reorder. */
164
+ export function narrateReordered(
165
+ sectionType: string,
166
+ from: number,
167
+ to: number,
168
+ total: number,
169
+ ): string {
170
+ return `${sectionType} moved from ${formatPosition(from, total)} to ${formatPosition(to, total)}.`;
171
+ }
172
+
173
+ /** Announcement for a Move Up / Move Down keyboard operation when the
174
+ * section can't move further (already at start/end). Used so the user
175
+ * isn't left wondering why the press didn't do anything. */
176
+ export function narrateMoveBlocked(sectionType: string, direction: 'up' | 'down'): string {
177
+ return `${sectionType} already at the ${direction === 'up' ? 'first' : 'last'} position.`;
178
+ }
179
+
180
+ /**
181
+ * Cross-zone move narration โ€” Phase 3b/B. Always position-AND-zone-based:
182
+ * the user needs to hear WHERE the section landed (which zone) plus
183
+ * its new ordinal so the next arrow press has a frame of reference.
184
+ *
185
+ * Sample output: `Hero moved from main, position 3 of 5, to sidebar, position 1 of 2.`
186
+ *
187
+ * Both endpoints carry the zone slug AND the per-row position. The
188
+ * caller computes positions against the LIVE arrays after mutation
189
+ * (so `toTotal` reflects the destination's new length).
190
+ */
191
+ export function narrateMovedToZone(
192
+ sectionType: string,
193
+ fromZone: string,
194
+ fromIdx: number,
195
+ fromTotal: number,
196
+ toZone: string,
197
+ toIdx: number,
198
+ toTotal: number,
199
+ ): string {
200
+ return `${sectionType} moved from ${fromZone}, ${formatPosition(fromIdx, fromTotal)}, to ${toZone}, ${formatPosition(toIdx, toTotal)}.`;
201
+ }
202
+
203
+ /** Undo / redo confirmation. The label describes the operation that
204
+ * was undone/redone. Routed through `announcePolite` (NOT assertive)
205
+ * per the channel design at the top of this file. */
206
+ export function narrateUndo(label: string): string {
207
+ return `Undid: ${label}.`;
208
+ }
209
+
210
+ export function narrateRedo(label: string): string {
211
+ return `Redid: ${label}.`;
212
+ }
213
+
214
+ /** Hotkey was pressed but the stack was empty in that direction. */
215
+ export function narrateUndoEmpty(): string {
216
+ return 'Nothing to undo.';
217
+ }
218
+
219
+ export function narrateRedoEmpty(): string {
220
+ return 'Nothing to redo.';
221
+ }
222
+
223
+ /**
224
+ * "+ Add row" outcome โ€” names the zone + the new row's position so a
225
+ * keyboard user knows where focus lands. Sample:
226
+ * `Row added in main, position 4 of 4.`
227
+ */
228
+ export function narrateRowAdded(zone: string, at: number, total: number): string {
229
+ return `Row added in ${zone}, ${formatPosition(at, total)}.`;
230
+ }
231
+
232
+ /**
233
+ * Row removal โ€” names the zone so the user has a frame of reference
234
+ * for what disappeared. Doesn't include the prior position because
235
+ * by announce-time the row is gone + neighboring rows have shifted.
236
+ */
237
+ export function narrateRowRemoved(zone: string): string {
238
+ return `Row removed from ${zone}.`;
239
+ }
240
+
241
+ /**
242
+ * Section removal (Phase 3d.1 โ€” Backspace / Delete).
243
+ *
244
+ * Names the section type + zone so the user knows WHICH section
245
+ * disappeared without having to count remaining ones. Mirrors
246
+ * narrateRowRemoved's "no position" rationale โ€” by announce-time
247
+ * the section is gone + neighbors have shifted, so position would
248
+ * be meaningless. The `Cmd+Z restores` hint is folded into the
249
+ * announcement so screen-reader users hear the recovery affordance
250
+ * (sighted users see the toolbar's enabled Undo button).
251
+ */
252
+ export function narrateSectionRemoved(sectionType: string, zone: string): string {
253
+ return `${sectionType} removed from ${zone}. Press Command+Z to undo.`;
254
+ }
255
+
256
+ /**
257
+ * Section duplication (Phase 3d.2 โ€” Cmd/Ctrl+D).
258
+ *
259
+ * The clone lands directly after the source so position is the
260
+ * source's index + 1. Naming both position + total gives the user
261
+ * a frame of reference for the next arrow press; matches
262
+ * narrateInserted's wording so screen-reader output stays consistent
263
+ * regardless of how the section got there (palette drop, duplicate,
264
+ * eventually paste).
265
+ */
266
+ export function narrateSectionDuplicated(
267
+ sectionType: string,
268
+ at: number,
269
+ total: number,
270
+ ): string {
271
+ return `${sectionType} duplicated at ${formatPosition(at, total)}.`;
272
+ }
273
+
274
+ /**
275
+ * Section resize โ€” drag of the right-edge handle OR Shift+Arrow keypress
276
+ * (Phase 3c). Always names the section type + the NEW span "X of 12"
277
+ * because plan ยง7.5's UX contract is "you can always read your current
278
+ * span aloud" โ€” sighted users see the on-screen pill, SR users need
279
+ * the same fact in the audio channel.
280
+ *
281
+ * Routed through `announce` (NOT `announcePolite`): resize is a
282
+ * positional state change like drag/drop, not informational like undo.
283
+ * The user is mid-gesture (or mid-keystroke) and the next press is
284
+ * relative to the NEW span โ€” they need to hear it NOW.
285
+ *
286
+ * Sample output: `Hero now spans 8 of 12 columns.`
287
+ *
288
+ * Pure helper โ€” no Vue. Callers (the resize composable + the hotkeys
289
+ * composable) pass their own announcer reference + the narrated text.
290
+ */
291
+ export function narrateResize(sectionType: string, newColSpan: number): string {
292
+ return `${sectionType} now spans ${newColSpan} of 12 columns.`;
293
+ }
294
+
295
+ /**
296
+ * Section resize hit a hard limit โ€” the user pushed past the section's
297
+ * `minColSpan` / `maxColSpan` OR the right neighbour's own min (Plan ยง7.5's
298
+ * "constraint snap"). Without this narration the keystroke / drag would
299
+ * fall silent at the boundary, leaving SR users wondering if the press
300
+ * registered.
301
+ *
302
+ * `reason` discriminates so the message names WHICH bound the user hit;
303
+ * the pill near the cursor shows the matching label visually
304
+ * ("๐Ÿ”’ min 3/12") for sighted users.
305
+ *
306
+ * 'section-min' โ†’ the resized section can't shrink further
307
+ * 'section-max' โ†’ the resized section can't grow further
308
+ * 'neighbour-min' โ†’ the right neighbour can't shrink any more
309
+ *
310
+ * The numeric bound is included so the user knows what to aim for
311
+ * with the next press ("can't go below 3 of 12 columns").
312
+ *
313
+ * Routed through `announce` (assertive) so it interrupts an in-flight
314
+ * resize narration โ€” the user's next press needs the updated frame of
315
+ * reference.
316
+ */
317
+ export function narrateResizeBlocked(
318
+ sectionType: string,
319
+ reason: 'section-min' | 'section-max' | 'neighbour-min',
320
+ bound: number,
321
+ ): string {
322
+ if (reason === 'section-min') {
323
+ return `${sectionType} can't go below ${bound} of 12 columns.`;
324
+ }
325
+ if (reason === 'section-max') {
326
+ return `${sectionType} can't go above ${bound} of 12 columns.`;
327
+ }
328
+ // 'neighbour-min' โ€” the bound is the neighbour's min. Wording names the
329
+ // neighbour as the blocker so the user knows resizing the OTHER section
330
+ // (or removing it) is what unblocks them.
331
+ return `${sectionType} can't grow further; next section at minimum ${bound} of 12 columns.`;
332
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * useLayoutAutoSave โ€” debounced auto-save for the layout editor.
3
+ *
4
+ * Phase 3a.6 + session-160 audit polish. Two complementary triggers:
5
+ * 1. Debounce: watch a dirty flag; on first dirt, wait `debounceMs`
6
+ * (default 1500 per docs/plans/layout-and-pages.md ยง7.13) then
7
+ * save. Further edits within the window reset the timer.
8
+ * 2. Visibility-change flush: when the tab becomes hidden (Cmd+Tab,
9
+ * tab close intent, minimize) and the draft is dirty, fire an
10
+ * immediate save. This is the safety net for users who edit
11
+ * then close the tab during the debounce window.
12
+ *
13
+ * Caller (the editor page) owns:
14
+ * - the dirty ref (from useLayoutEditor)
15
+ * - the save fn (from useLayoutEditor)
16
+ * - error/conflict handling โ€” save() throws on 409 and the page
17
+ * catches it; auto-save itself just swallows + lets the
18
+ * editor.status reflect the result
19
+ *
20
+ * Per UX research synthesis (session 160 audit): debounce alone loses
21
+ * data when the user Cmd-W's during the window; blur alone misses
22
+ * idle-keyboard edits; both together gives a "nothing was lost"
23
+ * mental model.
24
+ *
25
+ * The composable returns a `cancel()` for tests + manual pause; the
26
+ * timer is automatically cleared on component unmount.
27
+ */
28
+ import { onBeforeUnmount, onMounted, watch, type ComputedRef, type Ref } from 'vue';
29
+
30
+ export interface UseLayoutAutoSaveOptions {
31
+ /** Reactive dirty flag โ€” when true, schedule a save. */
32
+ dirty: ComputedRef<boolean> | Ref<boolean>;
33
+ /** Save function โ€” called on debounce-fire. */
34
+ save: () => Promise<void>;
35
+ /** Debounce window in ms. Default 1500. */
36
+ debounceMs?: number;
37
+ /** When true, skip scheduling entirely (e.g. user toggled auto-save off). */
38
+ paused?: Ref<boolean>;
39
+ }
40
+
41
+ export interface UseLayoutAutoSaveResult {
42
+ /** Stop the pending timer + ignore subsequent dirt until explicitly resumed. */
43
+ cancel: () => void;
44
+ }
45
+
46
+ export function useLayoutAutoSave(opts: UseLayoutAutoSaveOptions): UseLayoutAutoSaveResult {
47
+ const debounceMs = opts.debounceMs ?? 1500;
48
+ let timer: ReturnType<typeof setTimeout> | null = null;
49
+
50
+ function cancel(): void {
51
+ if (timer !== null) {
52
+ clearTimeout(timer);
53
+ timer = null;
54
+ }
55
+ }
56
+
57
+ watch(
58
+ opts.dirty,
59
+ (isDirty) => {
60
+ cancel();
61
+ if (!isDirty) return;
62
+ if (opts.paused?.value) return;
63
+ timer = setTimeout(() => {
64
+ timer = null;
65
+ // Errors are surfaced via the save() side-effects (editor.status,
66
+ // toasts). Swallow here so the watcher doesn't reject.
67
+ void opts.save().catch(() => {
68
+ /* handled by save()'s status setter */
69
+ });
70
+ }, debounceMs);
71
+ },
72
+ { flush: 'post' },
73
+ );
74
+
75
+ /**
76
+ * Visibility-change flush: when the tab is being hidden AND the draft
77
+ * is dirty, cancel the pending debounce and save IMMEDIATELY. This
78
+ * protects against data loss when the user Cmd+Tab's or closes the
79
+ * tab during the debounce window.
80
+ *
81
+ * `document.visibilityState === 'hidden'` fires reliably across modern
82
+ * browsers (per CanIUse: 100% support). The save() call is async and
83
+ * returns a promise that we don't await โ€” the browser may not give
84
+ * us time to finish, but firing the request is better than not.
85
+ *
86
+ * The "REAL safety" path (request that survives page teardown) is
87
+ * now wired separately: session 162 P2.3 added `editor.flushBeacon()`
88
+ * (fetch with `keepalive:true`) which the editor page calls from a
89
+ * `pagehide` listener. visibilitychange is the fast path; pagehide-
90
+ * +-beacon is the safety net for the actual teardown.
91
+ */
92
+ function onVisibilityChange(): void {
93
+ if (typeof document === 'undefined') return;
94
+ if (document.visibilityState !== 'hidden') return;
95
+ if (opts.paused?.value) return;
96
+ // Only flush if there's a pending dirty save
97
+ const isDirty = (opts.dirty as { value: boolean }).value;
98
+ if (!isDirty) return;
99
+ cancel();
100
+ void opts.save().catch(() => { /* handled */ });
101
+ }
102
+
103
+ onMounted(() => {
104
+ if (typeof document !== 'undefined') {
105
+ document.addEventListener('visibilitychange', onVisibilityChange);
106
+ }
107
+ });
108
+
109
+ onBeforeUnmount(() => {
110
+ cancel();
111
+ if (typeof document !== 'undefined') {
112
+ document.removeEventListener('visibilitychange', onVisibilityChange);
113
+ }
114
+ });
115
+
116
+ return { cancel };
117
+ }