srcdev-nuxt-components 9.1.59 → 9.2.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.
Files changed (28) hide show
  1. package/.claude/settings.json +5 -2
  2. package/.claude/skills/component-aria-landmark.md +42 -8
  3. package/.claude/skills/components/auto-grid.md +12 -6
  4. package/.claude/skills/components/banner-video.md +88 -23
  5. package/.claude/skills/components/page-hero-highlights.md +3 -1
  6. package/.claude/skills/components/page-row.md +14 -5
  7. package/.claude/skills/components/profile-section.md +1 -1
  8. package/.claude/skills/components/services-section.md +1 -1
  9. package/.claude/skills/index.md +2 -2
  10. package/.claude/skills/testing-add-unit-test.md +18 -0
  11. package/app/components/01.atoms/banner-video/BannerVideo.vue +62 -29
  12. package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +120 -0
  13. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +53 -2
  14. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +95 -0
  15. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +2 -2
  16. package/app/components/01.atoms/grids/data-grid/AutoGrid.vue +1 -2
  17. package/app/components/01.atoms/grids/data-grid/tests/AutoGrid.spec.ts +11 -19
  18. package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
  19. package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +28 -3
  20. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +2 -2
  21. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +3 -3
  22. package/app/components/03.organisms/services/services-section/ServicesSection.vue +2 -1
  23. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +12 -0
  24. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +1 -1
  25. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +2 -2
  26. package/app/composables/tests/useAriaLabelledById.spec.ts +7 -2
  27. package/app/composables/useAriaLabelledById.ts +18 -1
  28. package/package.json +1 -1
@@ -57,11 +57,14 @@
57
57
  "Bash(git fetch *)",
58
58
  "Bash(python3 -m json.tool /Users/simoncornforth/websites/nuxt-components/.vscode/srcdev-component-services-section.code-snippets)",
59
59
  "Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('/Users/simoncornforth/websites/nuxt-components/.vscode/srcdev-component-services-section.code-snippets','utf8'\\)\\); console.log\\('valid json'\\)\")",
60
- "WebFetch(domain:luxury-locs-by-natasha-nuxt3.vercel.app)"
60
+ "WebFetch(domain:luxury-locs-by-natasha-nuxt3.vercel.app)",
61
+ "Bash(cd /Users/simoncornforth/websites/nuxt-components *)",
62
+ "Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('/Users/simoncornforth/websites/nuxt-components/.vscode/srcdev-component-banner-video.code-snippets','utf8'\\)\\); console.log\\('valid json'\\)\")"
61
63
  ],
62
64
  "additionalDirectories": [
63
65
  "/Users/simoncornforth/websites/instepreflexology",
64
- "/Users/simoncornforth/websites/nuxt-components/.vscode"
66
+ "/Users/simoncornforth/websites/nuxt-components/.vscode",
67
+ "/Users/simoncornforth/websites/nuxt-components/.claude/skills/components"
65
68
  ]
66
69
  }
67
70
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- Components that accept a `tag` prop may render as a semantic landmark element (`section`, `main`, `article`, `aside`). Landmarks benefit from an accessible name via `aria-labelledby` pointing to a heading inside them. The `useAriaLabelledById` composable handles this automatically.
5
+ Components that accept a `tag` prop may render as a semantic landmark element (`section`, `article`, `aside` — not `main`, see below). Landmarks benefit from an accessible name via `aria-labelledby` pointing to a heading inside them. The `useAriaLabelledById` composable handles generating and applying the attribute — but **binding the id to an actual heading is still the consumer's responsibility**; see "Built-in safety net" below for how that mistake gets caught.
6
6
 
7
7
  ## The composable
8
8
 
@@ -13,7 +13,29 @@ Returns `{ headingId, ariaLabelledby }`:
13
13
  - `headingId` — a stable ID (via `useId()`) to place on the heading element inside the slot
14
14
  - `ariaLabelledby` — computed: set to `headingId` when `tag` is a landmark, `undefined` otherwise (which removes the attribute entirely)
15
15
 
16
- Labelled tags: `section`, `main`, `article`, `aside`.
16
+ Labelled tags: `section`, `article`, `aside`.
17
+
18
+ **`main` is deliberately excluded.** A `<main>` landmark doesn't need an accessible name unless a
19
+ page has more than one — auto-labelling it produced broken references in practice (the layout's
20
+ top-level `<main>` wraps arbitrary page content with no single natural heading to bind).
21
+
22
+ ### Built-in safety net
23
+
24
+ The composable itself checks, `onMounted`, whether `document.getElementById(headingId)` actually
25
+ resolves whenever `ariaLabelledby` is set. If it doesn't, it `console.warn`s immediately with the
26
+ tag name and id. This exists because the whole pattern is opt-in for the *consumer* — the
27
+ composable has no way to force a heading to bind, so instead it makes the mistake loud instead of
28
+ silent. **Do not treat the absence of a compile-time/type error as confirmation this is wired up
29
+ correctly** — always check the console (or run an accessibility audit) after adding `tag="section"`
30
+ (or `article`/`aside`) to any component using this pattern.
31
+
32
+ This bug class previously shipped to production undetected: eight sections across one site had
33
+ `aria-labelledby` pointing at ids that were never applied to anything, only surfaced by a WAVE
34
+ audit. Two components (`ServicesSection`, `AutoGrid`) were also found to be *structurally* broken
35
+ — they set `aria-labelledby` from `tag` alone without ever exposing `headingId` anywhere a
36
+ consumer could bind it, so it was impossible to satisfy correctly. `ServicesSection` now binds
37
+ `headingId` to its own internal title heading; `AutoGrid` has no heading concept at all, so it no
38
+ longer sets `aria-labelledby` under any circumstance (pass `aria-label` directly if needed).
17
39
 
18
40
  ## Usage in a component
19
41
 
@@ -55,14 +77,26 @@ When `tag="div"` (default), the `aria-labelledby` attribute is absent and `headi
55
77
 
56
78
  ## Components already using this pattern
57
79
 
58
- - `PageHeroHighlights` (04.templates)
59
- - `ProfileSection` (02.molecules)
60
- - `ServicesSection` (03.organisms)
61
- - `LayoutGridByCols` (01.atoms)
62
- - `LayoutGridByWidth` (01.atoms)
80
+ Consumer-bound (the component exposes `headingId` via a slot prop; whoever uses the component
81
+ must bind it to their own heading):
82
+
83
+ - `PageRow` (01.atoms) — default slot
84
+ - `PageHeroHighlights` (04.templates) — `#header` slot
85
+ - `ProfileSection` (02.molecules) — `#heroText` slot
86
+
87
+ Self-bound (the component renders its own heading and binds `headingId` internally — no consumer
88
+ action needed):
89
+
90
+ - `ServicesSection` (03.organisms) — binds it to its own title `HeroText`
91
+ - `LayoutGridByCols` / `LayoutGridByWidth` (01.atoms) — render their own visually-hidden `<p>` from the `label` prop
92
+
93
+ Not using this pattern:
94
+
95
+ - `AutoGrid` (01.atoms) — has no heading concept; never sets `aria-labelledby` regardless of `tag`
63
96
 
64
97
  ## Notes
65
98
 
66
- - If a component does not expose a named slot with `:heading-id`, the `headingId` is still generated — the consumer simply places their own heading inside the slot without binding the id.
99
+ - If a component does not expose a named slot with `:heading-id`, the `headingId` is still generated — the consumer simply places their own heading inside the slot without binding the id. **This is the failure mode to watch for** — nothing prevents it at compile time, only the runtime console warning described above.
67
100
  - `headingId` is stable across renders (SSR-safe via `useId()`).
68
101
  - Do not replicate the old manual pattern (`const needsLabel = computed(() => props.tag === "section")`) — use this composable instead.
102
+ - When adding `useAriaLabelledById` to a *new* component, prefer the self-bound pattern (render the heading yourself, or a `LayoutGridByCols`-style hidden label from a `label` prop) over the consumer-bound pattern wherever the component already controls its own heading markup — it structurally cannot go wrong the way the consumer-bound pattern can.
@@ -91,11 +91,16 @@ Override `grid-template-columns` directly — there is no single token for this:
91
91
  </AutoGrid>
92
92
  ```
93
93
 
94
- ### Semantic section with auto aria-labelledby
94
+ ### Semantic section with an accessible name
95
+
96
+ `AutoGrid` has no heading concept of its own — its slots are arbitrary named items, not a
97
+ header + body — so it does **not** auto-generate `aria-labelledby` the way `PageRow` or
98
+ `ServicesSection` do (an earlier version of this component did attempt to, and it produced a
99
+ guaranteed broken ARIA reference, since there was never any way to bind a heading to it). If
100
+ `tag="section"` needs an accessible name, pass `aria-label` directly:
95
101
 
96
102
  ```vue
97
- <AutoGrid tag="section">
98
- <!-- aria-labelledby is wired automatically via useAriaLabelledById -->
103
+ <AutoGrid tag="section" aria-label="Practice stats">
99
104
  <template #item-1><div>Item 1</div></template>
100
105
  <template #item-2><div>Item 2</div></template>
101
106
  </AutoGrid>
@@ -128,9 +133,10 @@ const stats = [
128
133
 
129
134
  ## Accessibility
130
135
 
131
- - When `tag` is `section`, `article`, or `main`, `aria-labelledby` is automatically set via `useAriaLabelledById`, pointing to a generated heading ID.
132
- - When `tag="div"`, no ARIA attributes are added.
133
- - Ensure a heading element with the matching ID is present inside the grid when using semantic tags.
136
+ - `AutoGrid` never sets `aria-labelledby` automatically, regardless of `tag` it has no heading
137
+ to point to. Pass `aria-label` (or wrap it in a `PageRow`/other component that does own a
138
+ heading) if a landmark tag needs an accessible name.
139
+ - No ARIA attributes are added by default for any `tag` value.
134
140
 
135
141
  See [component-aria-landmark.md](../component-aria-landmark.md) for the full landmark pattern.
136
142
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: BannerVideo
3
- description: BannerVideo full-width hero video banner — props, depth tier system, verticalPosition/horizontalPosition, reduced-motion fallback, CSS tokens, consumer styling
3
+ description: BannerVideo full-width hero video banner — props, depth tier system, verticalPosition/horizontalPosition, playIcon/pauseIcon/toggle-icon slot, reduced-motion fallback, CSS tokens, consumer styling
4
4
  type: reference
5
5
  ---
6
6
 
@@ -8,9 +8,9 @@ type: reference
8
8
 
9
9
  ## Overview
10
10
 
11
- `BannerVideo` renders a full-width banner section that plays a muted, looping mp4 video. A poster image is shown as fallback when the user has `prefers-reduced-motion: reduce` set — handled entirely in CSS, no JS.
11
+ `BannerVideo` renders a full-width banner section that plays a muted, looping mp4 video. A poster image is shown as fallback when the user has `prefers-reduced-motion: reduce` set — handled entirely in CSS, no JS. A visible pause/play toggle is always rendered over the video (see "Pause/play toggle" below) to satisfy WCAG 2.2.2 (Pause, Stop, Hide), since the video autoplays and loops indefinitely.
12
12
 
13
- The banner is sized via `aspect-ratio` so it scales naturally. The `depth` prop selects a responsive `max-height` tier (`xs` → `xl`) implemented with `clamp()` — no breakpoint props needed. Each tier exposes a `--theme-banner-video-max-height-{depth}` CSS token that consuming pages can override.
13
+ The banner is sized via `aspect-ratio` so it scales naturally. The `depth` prop selects a responsive `max-height` tier (`xs` → `xl`) implemented with `clamp()` — no breakpoint props needed. Each tier exposes a `--banner-video-max-height-{depth}` CSS token that consuming pages can override.
14
14
 
15
15
  ### Autoplay mechanism
16
16
 
@@ -26,13 +26,21 @@ The video uses `autoplay muted loop playsinline preload="auto"` attributes on th
26
26
  | `imgWidth` | `number` | `1920` | Intrinsic width of the poster image — required for NuxtImg/IPX optimisation. |
27
27
  | `imgHeight` | `number` | `1080` | Intrinsic height of the poster image — required for NuxtImg/IPX optimisation. |
28
28
  | `tag` | `"section" \| "div" \| "header" \| "main" \| "article"` | `"section"` | HTML element rendered as the root. |
29
- | `depth` | `"xs" \| "sm" \| "md" \| "lg" \| "xl"` | `"md"` | Responsive max-height tier. Each maps to a `clamp()` scale; override via `--theme-banner-video-max-height-{depth}`. |
29
+ | `depth` | `"xs" \| "sm" \| "md" \| "lg" \| "xl"` | `"md"` | Responsive max-height tier. Each maps to a `clamp()` scale; override via `--banner-video-max-height-{depth}`. |
30
30
  | `aspectRatio` | `string` | `"21/9"` | CSS `aspect-ratio` of the container (e.g. `"16/9"`, `"21/9"`, `"4/3"`). |
31
31
  | `objectFit` | `"cover" \| "contain" \| "fill" \| "none" \| "scale-down"` | `"cover"` | How the video and fallback image fill the banner frame. |
32
32
  | `verticalPosition` | `"start" \| "center" \| "end"` | `"center"` | Vertical crop position. Maps to `align-self` on the video element and `object-position` Y on the fallback image. |
33
33
  | `horizontalPosition` | `"start" \| "center" \| "end"` | `"center"` | Horizontal crop position. Maps to `object-position` X on the fallback image. |
34
+ | `playIcon` | `string` | `"mdi:play"` | Iconify icon name shown on the toggle button before playback starts. |
35
+ | `pauseIcon` | `string` | `"mdi:pause"` | Iconify icon name shown on the toggle button once playback starts. |
34
36
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes applied to the root element. |
35
37
 
38
+ ## Slots
39
+
40
+ | Slot | Slot props | Purpose |
41
+ |---|---|---|
42
+ | `toggle-icon` | `{ isPlaying: boolean }` | Replaces the toggle button's icon entirely. Default renders `<Icon :name="isPlaying ? pauseIcon : playIcon" />` — use this slot instead of `playIcon`/`pauseIcon` when an Iconify name isn't enough (custom SVG, different icon set). |
43
+
36
44
  ## Basic usage
37
45
 
38
46
  ```vue
@@ -87,6 +95,20 @@ The video uses `autoplay muted loop playsinline preload="auto"` attributes on th
87
95
  />
88
96
  ```
89
97
 
98
+ ### Custom toggle icon
99
+
100
+ ```vue
101
+ <!-- Swap the Iconify icon name -->
102
+ <BannerVideo src="…" poster="…" play-icon="mdi:play-circle" pause-icon="mdi:pause-circle" />
103
+
104
+ <!-- Replace the icon entirely (custom SVG, different icon set) -->
105
+ <BannerVideo src="…" poster="…">
106
+ <template #toggle-icon="{ isPlaying }">
107
+ <MyCustomIcon :name="isPlaying ? 'pause' : 'play'" />
108
+ </template>
109
+ </BannerVideo>
110
+ ```
111
+
90
112
  ## Positioning explained
91
113
 
92
114
  The video element uses `height: auto; min-height: 100%` so it overflows the container naturally when aspect ratios differ. `overflow: hidden` on the root clips it. `verticalPosition` maps to `align-self` on the video (shifting which portion of the overflow is visible) and to `object-position` Y on the fallback image.
@@ -135,20 +157,55 @@ Inline style tokens (set from props):
135
157
 
136
158
  | depth | token | clamp value |
137
159
  |---|---|---|
138
- | `xs` | `--theme-banner-video-max-height-xs` | `clamp(12rem, 15vw, 24rem)` |
139
- | `sm` | `--theme-banner-video-max-height-sm` | `clamp(18rem, 22vw, 36rem)` |
140
- | `md` | `--theme-banner-video-max-height-md` | `clamp(28rem, 38vw, 56rem)` |
141
- | `lg` | `--theme-banner-video-max-height-lg` | `clamp(40rem, 52vw, 72rem)` |
142
- | `xl` | `--theme-banner-video-max-height-xl` | `clamp(52rem, 65vw, 90rem)` |
160
+ | `xs` | `--banner-video-max-height-xs` | `clamp(12rem, 15vw, 24rem)` |
161
+ | `sm` | `--banner-video-max-height-sm` | `clamp(18rem, 22vw, 36rem)` |
162
+ | `md` | `--banner-video-max-height-md` | `clamp(28rem, 38vw, 56rem)` |
163
+ | `lg` | `--banner-video-max-height-lg` | `clamp(40rem, 52vw, 72rem)` |
164
+ | `xl` | `--banner-video-max-height-xl` | `clamp(52rem, 65vw, 90rem)` |
143
165
 
144
166
  **Override a tier in a consuming page:**
145
167
 
146
168
  ```css
147
169
  .my-page {
148
- --theme-banner-video-max-height-md: clamp(32rem, 45vw, 64rem);
170
+ --banner-video-max-height-md: clamp(32rem, 45vw, 64rem);
149
171
  }
150
172
  ```
151
173
 
174
+ ### Toggle button token defaults
175
+
176
+ | token | default | controls |
177
+ |---|---|---|
178
+ | `--banner-video-toggle-size` | `3.2rem` | Width and height of the toggle button |
179
+ | `--banner-video-toggle-offset` | `1.2rem` | Margin from the bottom-right corner of the banner |
180
+ | `--banner-video-toggle-background` | `oklch(0% 0 0 / 0.4)` | Button background |
181
+ | `--banner-video-toggle-background-hover` | `oklch(0% 0 0 / 0.6)` | Button background on hover/focus |
182
+ | `--banner-video-toggle-icon-color` | `white` | Icon colour |
183
+
184
+ Full details, plus global/page-scoped/per-instance override patterns, live in the
185
+ component's own `CONSUMER-STYLING.md`.
186
+
187
+ ## Pause/play toggle
188
+
189
+ A `<button class="banner-video__toggle">` is always rendered in the bottom-right corner of the
190
+ banner, overlaying the video. It calls `videoEl.pause()` / `.play()` directly and its
191
+ `aria-label` reflects actual playback state ("Pause background video" / "Play background video"),
192
+ tracked via the video's native `play`/`pause` events rather than assumed — autoplay can be
193
+ blocked by the browser, so the label starts as "Play background video" until a `play` event
194
+ actually fires.
195
+
196
+ This exists to satisfy **WCAG 2.2.2 (Pause, Stop, Hide)**: content that autoplays and lasts more
197
+ than 5 seconds needs an on-page mechanism to pause it. The `prefers-reduced-motion` fallback only
198
+ covers users who have set that OS-level preference — everyone else still needs a way to stop the
199
+ loop, which is why the toggle is unconditional, not opt-in via a prop.
200
+
201
+ The toggle is hidden under `prefers-reduced-motion: reduce` (alongside the `<video>` itself,
202
+ since the fallback image is already static and has nothing to pause).
203
+
204
+ No captions/transcript are needed for this component's use case (silent, decorative ambient
205
+ background video) — if a consumer ever uses `BannerVideo` for a video that conveys information,
206
+ that content needs its own accessible alternative (transcript, captions) handled outside this
207
+ component, since `BannerVideo` has no slot for it.
208
+
152
209
  ## CSS classes
153
210
 
154
211
  | Class | Applied when |
@@ -156,6 +213,7 @@ Inline style tokens (set from props):
156
213
  | `.banner-video` | Always — the root element |
157
214
  | `.video` | The `<video>` element |
158
215
  | `.fallback` | The `<NuxtImg>` fallback — hidden by default, shown via `prefers-reduced-motion` CSS |
216
+ | `.banner-video__toggle` | The pause/play button — always rendered except under `prefers-reduced-motion: reduce` |
159
217
 
160
218
  ## Consumer styling
161
219
 
@@ -164,12 +222,19 @@ Use an unscoped style block scoped by a page or section wrapper class. No `:deep
164
222
  ```vue
165
223
  <style>
166
224
  .my-page {
167
- /* Override the md tier's clamp range for this page */
168
- --theme-banner-video-max-height-md: clamp(32rem, 45vw, 64rem);
225
+ .banner-video {
226
+ /* Override the md tier's clamp range and the toggle's colours for this page */
227
+ --banner-video-max-height-md: clamp(32rem, 45vw, 64rem);
228
+ --banner-video-toggle-background: var(--brand-overlay);
229
+ --banner-video-toggle-background-hover: var(--brand-overlay-strong);
230
+ }
169
231
  }
170
232
  </style>
171
233
  ```
172
234
 
235
+ See the component's `CONSUMER-STYLING.md` for the full token API and global/page-scoped/
236
+ per-instance override patterns.
237
+
173
238
  ## Gotcha: aspect-ratio is overridden by max-height at wide viewports
174
239
 
175
240
  `max-height` from the depth tier silently wins over `aspect-ratio` once the viewport is wide enough. For example, at `depth="md"` the max-height clamp caps at `56rem` — so changing `aspectRatio` from `"21/9"` to `"1/1"` produces no visible change on a wide desktop because `max-height` is the binding constraint.
@@ -178,11 +243,11 @@ Use an unscoped style block scoped by a page or section wrapper class. No `:deep
178
243
 
179
244
  ```css
180
245
  .my-page {
181
- --theme-banner-video-max-height-xs: 80rem;
182
- --theme-banner-video-max-height-sm: 80rem;
183
- --theme-banner-video-max-height-md: 80rem;
184
- --theme-banner-video-max-height-lg: 80rem;
185
- --theme-banner-video-max-height-xl: 80rem;
246
+ --banner-video-max-height-xs: 80rem;
247
+ --banner-video-max-height-sm: 80rem;
248
+ --banner-video-max-height-md: 80rem;
249
+ --banner-video-max-height-lg: 80rem;
250
+ --banner-video-max-height-xl: 80rem;
186
251
  }
187
252
  ```
188
253
 
@@ -190,11 +255,11 @@ Or via inline `:style` on the parent element (useful for dev QA exploration):
190
255
 
191
256
  ```vue
192
257
  <div :style="{
193
- '--theme-banner-video-max-height-xs': '80rem',
194
- '--theme-banner-video-max-height-sm': '80rem',
195
- '--theme-banner-video-max-height-md': '80rem',
196
- '--theme-banner-video-max-height-lg': '80rem',
197
- '--theme-banner-video-max-height-xl': '80rem',
258
+ '--banner-video-max-height-xs': '80rem',
259
+ '--banner-video-max-height-sm': '80rem',
260
+ '--banner-video-max-height-md': '80rem',
261
+ '--banner-video-max-height-lg': '80rem',
262
+ '--banner-video-max-height-xl': '80rem',
198
263
  }">
199
264
  <BannerVideo aspect-ratio="1/1" depth="lg" ... />
200
265
  </div>
@@ -205,6 +270,6 @@ You must override **all five tier tokens** — overriding only the active depth
205
270
  ## Notes
206
271
 
207
272
  - `loading="eager"` and `decoding="async"` are hardcoded on the fallback `NuxtImg` — it is above the fold by definition.
208
- - The video has `autoplay muted loop playsinline preload="auto"` — intentional and not configurable. This component is for ambient background video only, not user-controlled media.
273
+ - The video has `autoplay muted loop playsinline preload="auto"` — intentional and not configurable. This component is for ambient background video only, not a media player — the pause/play toggle exists purely for WCAG 2.2.2 compliance, not as a general playback UI (no seek bar, volume, etc.).
209
274
  - `prefers-reduced-motion` is handled in CSS (`.video { display: none }` + `.fallback { display: block }`), not via JS.
210
275
  - Storybook: the `"none"` image provider is active, so `poster` paths pass through unchanged. Always provide explicit `img-width` and `img-height` to avoid the `w=1536` fallback in deployed Storybook.
@@ -147,7 +147,7 @@ Omit `#end` for a single-element header — `#start` fills full width with no la
147
147
 
148
148
  ## With aria-labelledby (section tag)
149
149
 
150
- When `tag="section"`, `aria-labelledby` is set automatically. Wire the heading id via the scoped slot prop:
150
+ When `tag="section"`, `aria-labelledby` is set automatically. Wire the heading id via the scoped slot prop — if you don't, a console warning fires (from `useAriaLabelledById`) flagging the broken ARIA reference; see [component-aria-landmark.md](../component-aria-landmark.md):
151
151
 
152
152
  ```vue
153
153
  <PageHeroHighlights tag="section">
@@ -158,6 +158,8 @@ When `tag="section"`, `aria-labelledby` is set automatically. Wire the heading i
158
158
  </PageHeroHighlights>
159
159
  ```
160
160
 
161
+ `tag="main"` renders a `<main>` element but is never auto-labelled — a `<main>` landmark doesn't need an accessible name unless a page has more than one.
162
+
161
163
  See [component-aria-landmark.md](../component-aria-landmark.md) for the full pattern.
162
164
 
163
165
  ## Equal-width highlights
@@ -84,9 +84,13 @@ boundary at the track line. Useful for asymmetric imagery, pull-quotes, or decor
84
84
 
85
85
  ## Accessibility — aria-labelledby
86
86
 
87
- When `tag` is `section`, `main`, or `article`, `PageRow` automatically generates an
88
- `aria-labelledby` attribute pointing to the first heading inside. Bind the `headingId` slot prop
89
- to that heading's `id` to complete the association.
87
+ When `tag` is `section`, `article`, or `aside`, `PageRow` automatically generates an
88
+ `aria-labelledby` attribute pointing to the first heading inside. **You must bind the
89
+ `headingId` slot prop to that heading's `id`** — `PageRow` cannot verify you did this, since the
90
+ slot content is arbitrary. If you forget, the section gets an `aria-labelledby` pointing at an id
91
+ that exists nowhere in the DOM, which accessibility audits (WAVE, axe) flag as a broken ARIA
92
+ reference. A console warning fires in the browser (from `useAriaLabelledById`) the moment a
93
+ mounted instance is missing its matching heading — check the console if you see this warning.
90
94
 
91
95
  ```vue
92
96
  <PageRow tag="section">
@@ -97,8 +101,13 @@ to that heading's `id` to complete the association.
97
101
  </PageRow>
98
102
  ```
99
103
 
100
- Non-landmark tags (`div`, `header`, `footer`, `nav`) do not receive `aria-labelledby`. The
101
- `headingId` slot prop is still provided but can be ignored.
104
+ `tag="main"` is deliberately **not** auto-labelled a `<main>` landmark doesn't need an
105
+ accessible name unless a page has more than one. Other non-landmark tags (`div`, `header`,
106
+ `footer`, `nav`) also do not receive `aria-labelledby`. The `headingId` slot prop is still
107
+ provided in both cases but can be ignored.
108
+
109
+ See [component-aria-landmark.md](../component-aria-landmark.md) for the full pattern and the
110
+ list of components that share it.
102
111
 
103
112
  ---
104
113
 
@@ -8,7 +8,7 @@ type: reference
8
8
 
9
9
  ## Overview
10
10
 
11
- `ProfileSection` is a molecule that renders a practitioner/author profile: a header area (eyebrow + heading), a profile picture, and a flexible set of bio/info blocks alongside optional profile links. It is landmark-aware — the root element automatically gets `aria-labelledby` wired to the heading inside the `#heroText` slot.
11
+ `ProfileSection` is a molecule that renders a practitioner/author profile: a header area (eyebrow + heading), a profile picture, and a flexible set of bio/info blocks alongside optional profile links. It is landmark-aware — when `tag` is `section`, `article`, or `aside`, the root element automatically gets `aria-labelledby` wired to the heading inside the `#heroText` slot (**you must bind `headingId` yourself, and a console warning fires if you forget** — see [component-aria-landmark.md](../component-aria-landmark.md)). `tag="main"` is never auto-labelled.
12
12
 
13
13
  ## Props
14
14
 
@@ -207,5 +207,5 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
207
207
  `services-section__glass-panel`. If a consuming app has CSS overrides referencing older
208
208
  unprefixed names (`.image-wrapper`, `.price-duration`, `.services-faq`, etc. — from before
209
209
  this component's classnames were namespaced), those selectors need updating to match.
210
- - The section gets `aria-labelledby` automatically when `tag` is `"section"` or `"article"`, pointing to the internal heading id.
210
+ - The section gets `aria-labelledby` automatically when `tag` is `"section"`, `"article"`, or `"aside"`, pointing at the id ServicesSection binds to its own title `HeroText` internally — no consumer action needed. (`tag="main"` renders a `<main>` element but is never auto-labelled; see [component-aria-landmark.md](../component-aria-landmark.md).)
211
211
  - `summaryAlignment` only has effect when `isSummary` is `true` — it aligns the info-wrapper content vertically within the grid cell.
@@ -37,7 +37,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
37
37
  ├── css-nesting-conventions.md — native CSS nesting rules: why &__child Sass BEM concatenation silently breaks, correct patterns
38
38
  ├── css-grid-max-width-gutters.md — cap a centre grid column width by growing gutters, with start/center alignment variants
39
39
  ├── css-animation-utilities.md — scroll-driven animation utility classes: scroller-x (carousel), entry-zoom-reveal, entry-slide-in, entry-exit-blur, auto-rotate
40
- ├── component-aria-landmark.md — useAriaLabelledById composable: aria-labelledby for section/main/article/aside tags
40
+ ├── component-aria-landmark.md — useAriaLabelledById composable: aria-labelledby for section/article/aside tags (not main), consumer-bound vs self-bound pattern, built-in broken-reference console warning
41
41
  ├── component-export-types.md — move inline component types to app/types/components/ barrel for consumer imports
42
42
  ├── component-inline-action-button.md — InputButtonCore variant="inline" pattern for buttons embedded in custom input wrappers
43
43
  ├── vue-video-autoplay.md — autoplay on client-side navigation: use <source> child (not :src on <video>), :key, and explicit v.load()
@@ -74,7 +74,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
74
74
  ├── glass-panel.md — GlassPanel props, slots, CSS token API (--glass-panel-bg/border-color/shadow/highlight), theming override
75
75
  ├── navigation-horizontal.md — NavigationHorizontal props, NavItemData type, CSS token API, import path gotcha
76
76
  ├── input-copy-core.md — InputCopyCore: readonly copy-to-clipboard input; props, emits, slots, CSS classes, usage
77
- ├── banner-video.md — BannerVideo: full-width hero video banner, depth tier system, objectFit/objectPosition, reduced-motion fallback, CSS tokens
77
+ ├── banner-video.md — BannerVideo: full-width hero video banner, depth tier system, objectFit/objectPosition, playIcon/pauseIcon/toggle-icon slot, reduced-motion fallback, CSS tokens
78
78
  ├── grid-stack.md — GridStack: CSS Grid z-axis stacking, slot API, z-order rules, sizing, video+overlay and image+text patterns
79
79
  ├── scroll-reveal-frame.md — ScrollRevealFrame: generic parallax clipping frame, slot API, image grid pattern, CSS tokens, browser support
80
80
  ├── scroll-reveal-image.md — ScrollRevealImage: single-image parallax reveal, focalX, imgWidth/imgHeight, responsive frame height
@@ -158,6 +158,24 @@ it("exposes headingId via scoped slot", async () => {
158
158
  });
159
159
  ```
160
160
 
161
+ ### Testing the aria-labelledby dev-warning (useAriaLabelledById)
162
+
163
+ Components using `useAriaLabelledById` (see [component-aria-landmark.md](component-aria-landmark.md))
164
+ `console.warn` on mount if `aria-labelledby` is set but no element in `document` has the matching
165
+ id. To assert this warning (or its absence) in a test, `mountSuspended` must attach to the real
166
+ document — by default VTU mounts into a detached container, so `document.getElementById` will
167
+ never find the slotted heading even when it's correctly bound, producing a false-positive warning:
168
+
169
+ ```ts
170
+ const wrapper = await mountSuspended(ComponentName, {
171
+ props: { tag: "section" },
172
+ attachTo: document.body, // required — see note above
173
+ slots: {
174
+ default: (props: { headingId: string }) => h("h2", { id: props.headingId }, "Title"),
175
+ },
176
+ });
177
+ ```
178
+
161
179
  > Returning a plain string from a slot function does **not** produce DOM — always use `h()`.
162
180
 
163
181
  ## Key rules
@@ -24,6 +24,8 @@
24
24
  :style="{ objectFit: props.objectFit }"
25
25
  @loadeddata="handleLoadedData"
26
26
  @canplay="handleCanPlay"
27
+ @play="isPlaying = true"
28
+ @pause="isPlaying = false"
27
29
  >
28
30
  <source :src="src" type="video/mp4" />
29
31
  </video>
@@ -38,40 +40,34 @@
38
40
  decoding="async"
39
41
  :style="{ objectFit: props.objectFit, objectPosition: imgObjectPosition }"
40
42
  />
43
+ <button
44
+ type="button"
45
+ class="banner-video__toggle"
46
+ :aria-label="isPlaying ? 'Pause background video' : 'Play background video'"
47
+ @click="togglePlayback"
48
+ >
49
+ <slot name="toggle-icon" :is-playing="isPlaying">
50
+ <Icon :name="isPlaying ? pauseIcon : playIcon" aria-hidden="true" />
51
+ </slot>
52
+ </button>
41
53
  </component>
42
54
  </template>
43
55
 
44
56
  <script setup lang="ts">
45
57
  interface Props {
46
- /** HTML element to render as the root. Defaults to `section` for landmark semantics. */
47
58
  tag?: "section" | "div" | "header" | "main" | "article";
48
- /** Path to the video source file (mp4). */
49
59
  src: string;
50
- /** Path to the fallback/poster image. Used as video poster and as the visible fallback
51
- * when the video cannot play or when the user prefers reduced motion. */
52
60
  poster: string;
53
61
  alt?: string;
54
- /** Intrinsic width of the poster image — required for NuxtImg optimisation. */
55
62
  imgWidth?: number;
56
- /** Intrinsic height of the poster image — required for NuxtImg optimisation. */
57
63
  imgHeight?: number;
58
- /**
59
- * Depth tier controlling the responsive max-height via a `clamp()` scale.
60
- * Each tier maps to a `--theme-banner-video-max-height-{depth}` CSS token that
61
- * consuming pages can override. Defaults to `"md"`.
62
- */
63
64
  depth?: "xs" | "sm" | "md" | "lg" | "xl";
64
- /**
65
- * CSS aspect-ratio of the banner container (e.g. `"16/9"`, `"21/9"`, `"4/3"`).
66
- * Provides the intrinsic height that `max-height` caps at larger viewport widths.
67
- */
68
65
  aspectRatio?: string;
69
- /** How the video and fallback image fill the banner frame. Defaults to `"cover"`. */
70
66
  objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down";
71
- /** Vertical crop position within the banner. Maps to `align-self` on the video and `object-position` on the fallback image. Defaults to `"center"`. */
72
67
  verticalPosition?: "start" | "center" | "end";
73
- /** Horizontal crop position within the banner. Maps to `object-position` on the fallback image. Defaults to `"center"`. */
74
68
  horizontalPosition?: "start" | "center" | "end";
69
+ playIcon?: string;
70
+ pauseIcon?: string;
75
71
  styleClassPassthrough?: string | string[];
76
72
  }
77
73
 
@@ -85,6 +81,8 @@ const props = withDefaults(defineProps<Props>(), {
85
81
  objectFit: "cover",
86
82
  verticalPosition: "center",
87
83
  horizontalPosition: "center",
84
+ playIcon: "mdi:play",
85
+ pauseIcon: "mdi:pause",
88
86
  styleClassPassthrough: () => [],
89
87
  });
90
88
 
@@ -106,12 +104,22 @@ watch(
106
104
  );
107
105
 
108
106
  const videoEl = shallowRef<HTMLVideoElement | null>(null);
107
+ const isPlaying = ref(false);
108
+
109
+ const togglePlayback = () => {
110
+ const v = videoEl.value;
111
+ if (!v) return;
112
+ if (v.paused) {
113
+ void v.play();
114
+ } else {
115
+ v.pause();
116
+ }
117
+ };
109
118
 
110
119
  const tryPlay = async () => {
111
120
  const v = videoEl.value;
112
121
  if (!v) return;
113
122
  try {
114
- // Ensure muted stays true — required for programmatic autoplay in all browsers
115
123
  v.muted = true;
116
124
  await v.play();
117
125
  } catch {
@@ -123,9 +131,7 @@ const kickOffLoad = async () => {
123
131
  await nextTick();
124
132
  const v = videoEl.value;
125
133
  if (!v) return;
126
- // Force the media element to (re)read its source child and begin fetching
127
134
  v.load();
128
- // Attempt immediate play; loadeddata/canplay handlers will retry once data arrives
129
135
  void tryPlay();
130
136
  };
131
137
 
@@ -136,7 +142,6 @@ const handleCanPlay = () => {
136
142
  void tryPlay();
137
143
  };
138
144
 
139
- // Runs on mount AND whenever src changes (covers route-change re-use edge cases)
140
145
  watch(
141
146
  () => props.src,
142
147
  () => {
@@ -145,7 +150,6 @@ watch(
145
150
  { immediate: true, flush: "post" }
146
151
  );
147
152
 
148
- // Extra safety: when the component becomes active again (e.g. returning via keep-alive)
149
153
  onActivated(() => {
150
154
  void kickOffLoad();
151
155
  });
@@ -154,7 +158,7 @@ onActivated(() => {
154
158
  <style lang="css">
155
159
  @layer components {
156
160
  .banner-video {
157
- --_max-height: var(--theme-banner-video-max-height, clamp(28rem, 38vw, 56rem));
161
+ --_max-height: var(--banner-video-max-height, clamp(28rem, 38vw, 56rem));
158
162
 
159
163
  display: grid;
160
164
  grid-template-areas: "media";
@@ -164,19 +168,19 @@ onActivated(() => {
164
168
  overflow: hidden;
165
169
 
166
170
  &[data-depth="xs"] {
167
- --_max-height: var(--theme-banner-video-max-height-xs, clamp(12rem, 15vw, 24rem));
171
+ --_max-height: var(--banner-video-max-height-xs, clamp(12rem, 15vw, 24rem));
168
172
  }
169
173
  &[data-depth="sm"] {
170
- --_max-height: var(--theme-banner-video-max-height-sm, clamp(18rem, 22vw, 36rem));
174
+ --_max-height: var(--banner-video-max-height-sm, clamp(18rem, 22vw, 36rem));
171
175
  }
172
176
  &[data-depth="md"] {
173
- --_max-height: var(--theme-banner-video-max-height-md, clamp(28rem, 38vw, 56rem));
177
+ --_max-height: var(--banner-video-max-height-md, clamp(28rem, 38vw, 56rem));
174
178
  }
175
179
  &[data-depth="lg"] {
176
- --_max-height: var(--theme-banner-video-max-height-lg, clamp(40rem, 52vw, 72rem));
180
+ --_max-height: var(--banner-video-max-height-lg, clamp(40rem, 52vw, 72rem));
177
181
  }
178
182
  &[data-depth="xl"] {
179
- --_max-height: var(--theme-banner-video-max-height-xl, clamp(52rem, 65vw, 90rem));
183
+ --_max-height: var(--banner-video-max-height-xl, clamp(52rem, 65vw, 90rem));
180
184
  }
181
185
 
182
186
  .video {
@@ -196,6 +200,32 @@ onActivated(() => {
196
200
  height: 100%;
197
201
  }
198
202
 
203
+ .banner-video__toggle {
204
+ grid-area: media;
205
+ align-self: end;
206
+ justify-self: end;
207
+ margin: var(--banner-video-toggle-offset, 1.2rem);
208
+ z-index: 1;
209
+
210
+ display: grid;
211
+ place-items: center;
212
+ width: var(--banner-video-toggle-size, 3.2rem);
213
+ height: var(--banner-video-toggle-size, 3.2rem);
214
+ padding: 0;
215
+
216
+ background-color: var(--banner-video-toggle-background, oklch(0% 0 0 / 0.4));
217
+ color: var(--banner-video-toggle-icon-color, white);
218
+ border: none;
219
+ border-radius: 100vw;
220
+ cursor: pointer;
221
+ transition: background-color 0.2s ease;
222
+
223
+ &:hover,
224
+ &:focus-visible {
225
+ background-color: var(--banner-video-toggle-background-hover, oklch(0% 0 0 / 0.6));
226
+ }
227
+ }
228
+
199
229
  @media (prefers-reduced-motion: reduce) {
200
230
  .video {
201
231
  display: none;
@@ -203,6 +233,9 @@ onActivated(() => {
203
233
  .fallback {
204
234
  display: block;
205
235
  }
236
+ .banner-video__toggle {
237
+ display: none;
238
+ }
206
239
  }
207
240
  }
208
241
  }