@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.
- package/README.md +41 -12
- package/components/LayoutRow.vue +944 -0
- package/components/LayoutSection.vue +1028 -0
- package/components/LayoutSlot.vue +104 -162
- package/components/PageFrame.vue +116 -0
- package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
- package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
- package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
- package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
- package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
- package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
- package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
- package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
- package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
- package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
- package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
- package/components/blocks/BlockDividerView.vue +52 -2
- package/components/homepage/ContentGridSection.vue +23 -1
- package/components/homepage/HeroSection.vue +69 -8
- package/components/sections/SectionCta.vue +175 -0
- package/components/sections/SectionLearning.vue +232 -0
- package/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useFeatures.ts +32 -5
- package/composables/useLayout.ts +46 -43
- package/composables/useLayoutAnnouncer.ts +332 -0
- package/composables/useLayoutAutoSave.ts +117 -0
- package/composables/useLayoutDrag.ts +290 -0
- package/composables/useLayoutEditor.ts +593 -0
- package/composables/useLayoutHistory.ts +583 -0
- package/composables/useLayoutHotkeys.ts +366 -0
- package/composables/useLayoutResize.ts +783 -0
- package/layouts/admin.vue +137 -24
- package/middleware/admin-layouts.ts +29 -0
- package/nuxt.config.ts +14 -0
- package/package.json +8 -5
- package/pages/[...customPath].vue +154 -0
- package/pages/admin/homepage.vue +46 -0
- package/pages/admin/index.vue +16 -0
- package/pages/admin/layouts/[id].vue +1110 -0
- package/pages/admin/layouts/index.vue +356 -0
- package/pages/explore.vue +16 -6
- package/sections/builtin/content-feed.ts +18 -29
- package/sections/builtin/contests.ts +30 -0
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +36 -0
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +29 -0
- package/sections/builtin/embed.ts +31 -0
- package/sections/builtin/gallery.ts +29 -0
- package/sections/builtin/heading.ts +14 -19
- package/sections/builtin/hero.ts +16 -51
- package/sections/builtin/hubs.ts +30 -0
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +30 -0
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +35 -0
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +38 -7
- package/server/api/admin/homepage/sections.put.ts +52 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
- package/server/api/admin/layouts/[id].delete.ts +33 -1
- package/server/api/admin/layouts/[id].put.ts +78 -0
- package/server/api/admin/layouts/index.post.ts +60 -4
- package/server/api/admin/layouts/migrate-homepage.post.ts +68 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- package/server/plugins/feature-flags-prime.ts +39 -0
- package/server/utils/layoutCache.ts +37 -1
- package/server/utils/validateSectionConfigs.ts +123 -0
- package/theme/base.css +1 -0
- package/components/sections/SectionContentFeed.vue +0 -160
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionImage.vue +0 -104
- 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
|
+
}
|