@commonpub/layer 0.24.0 → 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 (82) 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/composables/autoFormSchema.ts +319 -0
  23. package/composables/useAdminSidebar.ts +116 -0
  24. package/composables/useEditorChrome.ts +56 -0
  25. package/composables/useLayout.ts +34 -41
  26. package/composables/useLayoutAnnouncer.ts +332 -0
  27. package/composables/useLayoutAutoSave.ts +117 -0
  28. package/composables/useLayoutDrag.ts +290 -0
  29. package/composables/useLayoutEditor.ts +593 -0
  30. package/composables/useLayoutHistory.ts +583 -0
  31. package/composables/useLayoutHotkeys.ts +366 -0
  32. package/composables/useLayoutResize.ts +783 -0
  33. package/layouts/admin.vue +137 -24
  34. package/middleware/admin-layouts.ts +29 -0
  35. package/package.json +10 -7
  36. package/pages/[...customPath].vue +154 -0
  37. package/pages/admin/homepage.vue +46 -0
  38. package/pages/admin/index.vue +16 -0
  39. package/pages/admin/layouts/[id].vue +1110 -0
  40. package/pages/admin/layouts/index.vue +356 -0
  41. package/pages/explore.vue +16 -6
  42. package/sections/builtin/content-feed.ts +18 -29
  43. package/sections/builtin/contests.ts +11 -19
  44. package/sections/builtin/cta.ts +46 -0
  45. package/sections/builtin/custom-html.ts +16 -30
  46. package/sections/builtin/divider.ts +15 -17
  47. package/sections/builtin/editorial.ts +11 -21
  48. package/sections/builtin/embed.ts +31 -0
  49. package/sections/builtin/gallery.ts +29 -0
  50. package/sections/builtin/heading.ts +14 -19
  51. package/sections/builtin/hero.ts +16 -51
  52. package/sections/builtin/hubs.ts +11 -26
  53. package/sections/builtin/image.ts +12 -49
  54. package/sections/builtin/learning.ts +5 -13
  55. package/sections/builtin/markdown.ts +29 -0
  56. package/sections/builtin/paragraph.ts +14 -17
  57. package/sections/builtin/stats.ts +17 -18
  58. package/sections/builtin/video.ts +30 -0
  59. package/sections/registry.ts +11 -0
  60. package/server/api/admin/homepage/sections.put.ts +52 -1
  61. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  62. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  63. package/server/api/admin/layouts/[id].delete.ts +33 -1
  64. package/server/api/admin/layouts/[id].put.ts +78 -0
  65. package/server/api/admin/layouts/index.post.ts +60 -4
  66. package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
  67. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  68. package/server/api/layouts/by-route.get.ts +64 -12
  69. package/server/utils/layoutCache.ts +37 -1
  70. package/server/utils/validateSectionConfigs.ts +123 -0
  71. package/theme/base.css +1 -0
  72. package/components/sections/SectionContentFeed.vue +0 -160
  73. package/components/sections/SectionContests.vue +0 -193
  74. package/components/sections/SectionCustomHtml.vue +0 -70
  75. package/components/sections/SectionDivider.vue +0 -55
  76. package/components/sections/SectionEditorial.vue +0 -138
  77. package/components/sections/SectionHeading.vue +0 -78
  78. package/components/sections/SectionHero.vue +0 -164
  79. package/components/sections/SectionHubs.vue +0 -247
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
  82. package/components/sections/SectionStats.vue +0 -151
@@ -0,0 +1,116 @@
1
+ /**
2
+ * useAdminSidebar — state machine for the admin chrome's left sidebar.
3
+ *
4
+ * Two independent surfaces:
5
+ * 1. Desktop (≥768px): width can collapse from 200px to ~56px (icons-only).
6
+ * The user's preference is persisted to a cookie. Editor routes
7
+ * (`/admin/layouts/[id]`, `/admin/theme/edit/[id]`) auto-collapse to
8
+ * give the canvas more room; the user can override that for the
9
+ * current page visit only.
10
+ * 2. Mobile (<768px): drawer that slides in from the left, independent
11
+ * of the desktop collapse state. Closes when a nav link is clicked.
12
+ *
13
+ * Design (mirrors Linear/Figma/Notion editor-mode patterns):
14
+ * - userPref — persisted boolean (cookie); the user's "default"
15
+ * collapsed state. Cookie chosen over localStorage
16
+ * so SSR renders correctly first time — no
17
+ * hydration flash where the sidebar starts expanded
18
+ * and then snaps to collapsed once the client tick
19
+ * reads storage. Matches `useTheme`'s pattern.
20
+ * - sessionOverride — null | boolean; non-null only when the user manually
21
+ * toggled while on an editor route. Resets on route
22
+ * change so leaving the editor returns to userPref.
23
+ * - desktopCollapsed (computed):
24
+ * sessionOverride ?? (isEditorRoute ? true : userPref)
25
+ *
26
+ * Tests live in `__tests__/useAdminSidebar.test.ts` — cover SSR-safe
27
+ * hydration via mocked cookie, route-aware override, toggle persistence,
28
+ * and the mobile/desktop split.
29
+ *
30
+ * Wired into `layers/base/layouts/admin.vue` only.
31
+ *
32
+ * Sub-route caveat: `EDITOR_ROUTE_PATTERNS` use `$` end-anchors so
33
+ * `/admin/layouts/abc/preview` would NOT auto-collapse. Today no editor
34
+ * has sub-routes; if Phase 3b+ adds them, extend the regexes or use
35
+ * `startsWith` semantics.
36
+ */
37
+
38
+ const COOKIE_KEY = 'cpub-admin-sidebar-collapsed';
39
+
40
+ const EDITOR_ROUTE_PATTERNS: RegExp[] = [
41
+ /^\/admin\/layouts\/[^/]+$/, // /admin/layouts/[id] — Phase 3a layout editor
42
+ /^\/admin\/theme\/edit\/[^/]+$/, // /admin/theme/edit/[id] — session 154+156 theme editor
43
+ ];
44
+
45
+ export interface AdminSidebarApi {
46
+ /** Final computed: is the desktop sidebar collapsed right now? */
47
+ desktopCollapsed: ComputedRef<boolean>;
48
+ /** Mobile drawer open state. Independent of desktop. */
49
+ mobileOpen: Ref<boolean>;
50
+ /** Whether the current route is one we auto-collapse for. Exposed for callers that want to label things. */
51
+ isEditorRoute: ComputedRef<boolean>;
52
+ /** Toggle desktop collapse. On editor routes: session-only. Off editor routes: persists to localStorage. */
53
+ toggleDesktop: () => void;
54
+ /** Toggle mobile drawer. */
55
+ toggleMobile: () => void;
56
+ /** Close mobile drawer (used by nav link click handlers). */
57
+ closeMobile: () => void;
58
+ }
59
+
60
+ export function useAdminSidebar(): AdminSidebarApi {
61
+ const route = useRoute();
62
+
63
+ // Persistent user pref: cookie (server-readable → no hydration flash).
64
+ // `useCookie` returns a Ref bound bidirectionally to the cookie; setting
65
+ // `.value` writes Set-Cookie on the next SSR response or updates
66
+ // document.cookie on the client. Default = false (expanded). The cookie
67
+ // is only created when the user actually toggles (Nuxt useCookie doesn't
68
+ // emit Set-Cookie for unchanged default values).
69
+ const userPref = useCookie<boolean>(COOKIE_KEY, {
70
+ default: () => false,
71
+ maxAge: 60 * 60 * 24 * 365, // 1 year — sidebar pref is "forever"
72
+ path: '/',
73
+ sameSite: 'lax',
74
+ });
75
+
76
+ // Transient: useState so it survives in-layout navigation but doesn't
77
+ // persist across page reloads (it's a per-visit override).
78
+ const sessionOverride = useState<boolean | null>('cpub-admin-sidebar-override', () => null);
79
+ const mobileOpen = useState<boolean>('cpub-admin-sidebar-mobile-open', () => false);
80
+
81
+ const isEditorRoute = computed<boolean>(() =>
82
+ EDITOR_ROUTE_PATTERNS.some((p) => p.test(route.path))
83
+ );
84
+
85
+ const desktopCollapsed = computed<boolean>(() => {
86
+ if (sessionOverride.value !== null) return sessionOverride.value;
87
+ return isEditorRoute.value ? true : userPref.value;
88
+ });
89
+
90
+ // Route change clears the session override so leaving the editor route
91
+ // returns the sidebar to the user's persistent preference.
92
+ watch(() => route.path, () => {
93
+ sessionOverride.value = null;
94
+ });
95
+
96
+ function toggleDesktop(): void {
97
+ const next = !desktopCollapsed.value;
98
+ if (isEditorRoute.value) {
99
+ sessionOverride.value = next;
100
+ return;
101
+ }
102
+ userPref.value = next;
103
+ sessionOverride.value = null;
104
+ // No explicit storage write — `useCookie` syncs automatically.
105
+ }
106
+
107
+ function toggleMobile(): void {
108
+ mobileOpen.value = !mobileOpen.value;
109
+ }
110
+
111
+ function closeMobile(): void {
112
+ mobileOpen.value = false;
113
+ }
114
+
115
+ return { desktopCollapsed, mobileOpen, isEditorRoute, toggleDesktop, toggleMobile, closeMobile };
116
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * useEditorChrome — palette + inspector visibility for the layout editor.
3
+ *
4
+ * User-reported: the editor's 3-column shell (palette ~280px / canvas /
5
+ * inspector ~320px) squishes the canvas to ~tablet width even on a wide
6
+ * desktop. Content cards in the preview clipped mid-word ("VIDEO/AYBACK
7
+ * SYSTE"). Fix is to let the admin hide either pane independently.
8
+ *
9
+ * Each visibility flag persists via cookie (matches `useAdminSidebar` +
10
+ * `useTheme` — no SSR/CSR hydration flash; Nuxt's `useCookie` reads the
11
+ * cookie on the server). Cookie is only emitted when the user toggles —
12
+ * Nuxt skips Set-Cookie for unchanged defaults.
13
+ *
14
+ * Wired into `pages/admin/layouts/[id].vue` (the editor shell) + the
15
+ * toggle buttons in `components/admin/layouts/AdminLayoutsToolbar.vue`.
16
+ *
17
+ * Pattern: v-show on the panels themselves (preserves component state +
18
+ * scroll/focus across toggles) + a `cpub-admin-layouts-editor-body--*`
19
+ * class on the parent grid (re-flows `grid-template-columns`).
20
+ */
21
+
22
+ const PALETTE_COOKIE = 'cpub-editor-palette-hidden';
23
+ const INSPECTOR_COOKIE = 'cpub-editor-inspector-hidden';
24
+
25
+ export interface EditorChromeApi {
26
+ paletteHidden: Ref<boolean>;
27
+ inspectorHidden: Ref<boolean>;
28
+ togglePalette: () => void;
29
+ toggleInspector: () => void;
30
+ }
31
+
32
+ export function useEditorChrome(): EditorChromeApi {
33
+ const paletteHidden = useCookie<boolean>(PALETTE_COOKIE, {
34
+ default: () => false,
35
+ maxAge: 60 * 60 * 24 * 365, // 1 year
36
+ path: '/',
37
+ sameSite: 'lax',
38
+ });
39
+
40
+ const inspectorHidden = useCookie<boolean>(INSPECTOR_COOKIE, {
41
+ default: () => false,
42
+ maxAge: 60 * 60 * 24 * 365,
43
+ path: '/',
44
+ sameSite: 'lax',
45
+ });
46
+
47
+ function togglePalette(): void {
48
+ paletteHidden.value = !paletteHidden.value;
49
+ }
50
+
51
+ function toggleInspector(): void {
52
+ inspectorHidden.value = !inspectorHidden.value;
53
+ }
54
+
55
+ return { paletteHidden, inspectorHidden, togglePalette, toggleInspector };
56
+ }
@@ -19,49 +19,42 @@
19
19
  */
20
20
  import { computed } from 'vue';
21
21
  import type { Ref } from 'vue';
22
+ import type {
23
+ LayoutRecord,
24
+ LayoutSectionResolved,
25
+ LayoutRowResolved,
26
+ LayoutZone,
27
+ } from '@commonpub/server';
22
28
 
23
- export interface LayoutSection {
24
- id: string;
25
- order: number;
26
- type: string;
27
- config: Record<string, unknown>;
28
- colSpan: number;
29
- responsive: { sm?: number; md?: number; lg?: number } | null;
30
- enabled: boolean;
31
- visibility: { roles?: string[]; features?: string[]; hideAt?: ('sm' | 'md' | 'lg')[] } | null;
32
- schemaVersion: number;
33
- }
34
-
35
- export interface LayoutRow {
36
- id: string;
37
- order: number;
38
- config: {
39
- gap?: 'none' | 'sm' | 'md' | 'lg';
40
- align?: 'start' | 'center' | 'stretch';
41
- background?: string;
42
- paddingY?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
43
- } | null;
44
- sections: LayoutSection[];
45
- }
46
-
47
- export interface LayoutZoneClient {
48
- zone: string;
49
- rows: LayoutRow[];
50
- }
29
+ // Re-export the server-side resolved types under the existing client-
30
+ // facing names. Until session 162 these were locally-declared duplicates
31
+ // (manually kept in sync) — the R2 audit caught that AdminLayoutsCanvas
32
+ // hand-mapped LayoutRecord → LayoutPayload field-by-field, silently
33
+ // dropping any newly-added section field (e.g. a future `pinned` flag).
34
+ // Same-named single source of truth fixes that bug class.
35
+ export type LayoutSection = LayoutSectionResolved;
36
+ export type LayoutRow = LayoutRowResolved;
37
+ export type LayoutZoneClient = LayoutZone;
51
38
 
52
- export interface LayoutPayload {
53
- zones: LayoutZoneClient[];
54
- pageMeta: {
55
- title: string;
56
- description?: string;
57
- ogImage?: string;
58
- noindex?: boolean;
59
- ogType?: 'website' | 'article' | 'profile';
60
- access?: 'public' | 'members' | 'admin';
61
- frame?: 'narrow' | 'wide' | 'two-column' | 'three-column' | 'sidebar-left' | 'sidebar-right';
62
- } | null;
63
- state: 'draft' | 'published';
64
- }
39
+ /**
40
+ * The leaner client-facing view of a layout — only the fields the
41
+ * renderer needs (state for draft-gate, pageMeta for SEO, zones for
42
+ * structure). Structurally a `Pick` of the full server LayoutRecord
43
+ * so any LayoutRecord is assignable to LayoutPayload without
44
+ * transformation (session 162 P2.4 R2 audit fix).
45
+ *
46
+ * IMPORTANT CAVEAT because the row/section types are re-exports of
47
+ * the server's resolved types (above), any new field added to
48
+ * LayoutSectionResolved or LayoutRowResolved on the server will
49
+ * automatically flow through to the public render path here. That's
50
+ * usually what you want for genuinely public fields (e.g. a future
51
+ * `pinned: boolean` should be visible). But fields that should be
52
+ * admin-only (e.g. internal moderation tags) MUST be added to a
53
+ * separate server-side type, never to LayoutSectionResolved /
54
+ * LayoutRowResolved — otherwise they leak via /api/layouts/by-route.
55
+ * Session 162 audit note.
56
+ */
57
+ export type LayoutPayload = Pick<LayoutRecord, 'state' | 'pageMeta' | 'zones'>;
65
58
 
66
59
  export interface UseLayoutResult {
67
60
  /** The layout payload, or null if none exists / feature off. */
@@ -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
+ }