@commonpub/layer 0.24.0 → 0.25.1
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/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useLayout.ts +34 -41
- 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/package.json +11 -8
- 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 +11 -19
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +16 -30
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +11 -21
- 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 +11 -26
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +5 -13
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +17 -18
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +11 -0
- 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 +12 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- 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/SectionContests.vue +0 -193
- package/components/sections/SectionCustomHtml.vue +0 -70
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionEditorial.vue +0 -138
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionHubs.vue +0 -247
- package/components/sections/SectionImage.vue +0 -104
- package/components/sections/SectionParagraph.vue +0 -55
- 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
|
+
}
|
package/composables/useLayout.ts
CHANGED
|
@@ -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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|