srcdev-nuxt-components 9.1.28 → 9.1.29

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 (75) hide show
  1. package/.claude/settings.json +7 -2
  2. package/.claude/settings.local.json +2 -1
  3. package/.claude/skills/components/banner-video.md +186 -0
  4. package/.claude/skills/components/scroll-reveal-frame.md +136 -0
  5. package/.claude/skills/components/scroll-reveal-image.md +161 -0
  6. package/.claude/skills/index.md +7 -1
  7. package/.claude/skills/setup-postinstall.md +45 -0
  8. package/.claude/skills/theming-override-default.md +6 -0
  9. package/.claude/skills/theming-partial-override.md +131 -0
  10. package/.claude/skills/vue-video-autoplay.md +74 -0
  11. package/README.md +15 -5
  12. package/app/components/01.atoms/banner-video/BannerVideo.vue +201 -0
  13. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +191 -0
  14. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +374 -0
  15. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +13 -0
  16. package/app/components/01.atoms/scroll-reveal-frame/ScrollRevealFrame.vue +125 -0
  17. package/app/components/01.atoms/scroll-reveal-frame/stories/ScrollRevealFrame.stories.ts +166 -0
  18. package/app/components/01.atoms/scroll-reveal-frame/tests/ScrollRevealFrame.spec.ts +148 -0
  19. package/app/components/01.atoms/scroll-reveal-frame/tests/__snapshots__/ScrollRevealFrame.spec.ts.snap +13 -0
  20. package/app/components/01.atoms/scroll-reveal-image/ScrollRevealImage.vue +96 -0
  21. package/app/components/01.atoms/scroll-reveal-image/stories/ScrollRevealImage.stories.ts +181 -0
  22. package/app/components/01.atoms/scroll-reveal-image/tests/ScrollRevealImage.spec.ts +235 -0
  23. package/app/components/01.atoms/scroll-reveal-image/tests/__snapshots__/ScrollRevealImage.spec.ts.snap +13 -0
  24. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +1 -0
  25. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +1 -0
  26. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +1 -0
  27. package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +1 -0
  28. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -0
  29. package/app/components/03.organisms/services/services-grids/tests/ServicesSectionGrid.spec.ts +1 -0
  30. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +3 -3
  31. package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts +1 -0
  32. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +1 -0
  33. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +1 -1
  34. package/app/layouts/default.vue +2 -0
  35. package/app/pages/banner-video.vue +296 -0
  36. package/app/pages/ui/scroll-reveal-image.vue +483 -0
  37. package/app/types/types.services.ts +1 -0
  38. package/package.json +1 -1
  39. package/app/assets/styles/extends-layer/srcdev-components/components/_display-prompt-core.css +0 -94
  40. package/app/assets/styles/extends-layer/srcdev-components/components/_display-toast.css +0 -5
  41. package/app/assets/styles/extends-layer/srcdev-components/components/_expanding-panel.css +0 -37
  42. package/app/assets/styles/extends-layer/srcdev-components/components/index.css +0 -3
  43. package/app/assets/styles/extends-layer/srcdev-components/index.css +0 -1
  44. package/app/assets/styles/extends-layer/srcdev-forms/components/_form-fieldset.css +0 -38
  45. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-button.css +0 -67
  46. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-core.css +0 -87
  47. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-options-button.css +0 -74
  48. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-checkbox-radio-with-label.css +0 -14
  49. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-description.css +0 -13
  50. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-error.css +0 -54
  51. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-label.css +0 -21
  52. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-select.css +0 -143
  53. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-text.css +0 -190
  54. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-textarea.css +0 -96
  55. package/app/assets/styles/extends-layer/srcdev-forms/components/_input-toggle-switch.css +0 -68
  56. package/app/assets/styles/extends-layer/srcdev-forms/components/index.css +0 -12
  57. package/app/assets/styles/extends-layer/srcdev-forms/index.css +0 -2
  58. package/app/assets/styles/extends-layer/srcdev-forms/setup/_generic.css +0 -21
  59. package/app/assets/styles/extends-layer/srcdev-forms/setup/index.css +0 -3
  60. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_default.css +0 -13
  61. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_large.css +0 -12
  62. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_medium.css +0 -12
  63. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_small.css +0 -12
  64. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/_x-small.css +0 -11
  65. package/app/assets/styles/extends-layer/srcdev-forms/setup/sizes/index.css +0 -23
  66. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_default.css +0 -62
  67. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_error.css +0 -63
  68. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_ghost.css +0 -63
  69. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_info.css +0 -63
  70. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_primary.css +0 -66
  71. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_secondary.css +0 -64
  72. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_success.css +0 -63
  73. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_tertiary.css +0 -63
  74. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/_warning.css +0 -63
  75. package/app/assets/styles/extends-layer/srcdev-forms/setup/themes/index.css +0 -7
@@ -0,0 +1,131 @@
1
+ # Partial Token Override in a Consuming App
2
+
3
+ ## Overview
4
+
5
+ Use this skill when you need to override a specific category of tokens (e.g. form inputs, buttons,
6
+ colours) without replacing the entire default theme. The full token reference is in
7
+ `CONSUMER-STYLING.md` at the root of the layer package.
8
+
9
+ ## How it works
10
+
11
+ All library tokens are declared inside `@layer theming`. Any CSS written outside a layer wins
12
+ automatically — no `!important` or specificity tricks required. Override files just need to be
13
+ imported **after** the layer styles.
14
+
15
+ ## Steps
16
+
17
+ ### 1. Create your override file
18
+
19
+ Create a CSS file for the category you want to override. Place it anywhere in your app's assets —
20
+ `app/assets/styles/` is conventional.
21
+
22
+ **Example: form input overrides**
23
+
24
+ ```css
25
+ /* app/assets/styles/form-overrides.css */
26
+ :root {
27
+ --theme-input-surface: oklch(0.98 0.005 250);
28
+ --theme-input-border: oklch(0.45 0.08 270);
29
+ --theme-input-border-focus: oklch(0.55 0.18 280);
30
+ --theme-input-outline-focus: oklch(0.55 0.18 280);
31
+ --theme-focus-visible-shadow: 0 0 0 2px oklch(0.80 0.12 280);
32
+ --theme-input-placeholder: oklch(0.60 0.02 255);
33
+ --theme-input-text-color-normal: oklch(0.20 0.02 255);
34
+ }
35
+ ```
36
+
37
+ **Example: add a red colour scale and remap accent tokens**
38
+
39
+ ```css
40
+ /* app/assets/styles/red-palette.css */
41
+ :root {
42
+ /* Red scale — 00 (lightest) to 10 (darkest) */
43
+ --red-00: oklch(0.99 0.005 20);
44
+ --red-01: oklch(0.96 0.020 20);
45
+ --red-02: oklch(0.90 0.055 20);
46
+ --red-03: oklch(0.82 0.105 20);
47
+ --red-04: oklch(0.72 0.155 20);
48
+ --red-05: oklch(0.62 0.185 20);
49
+ --red-06: oklch(0.53 0.185 20);
50
+ --red-07: oklch(0.44 0.165 20);
51
+ --red-08: oklch(0.36 0.140 20);
52
+ --red-09: oklch(0.28 0.110 20);
53
+ --red-10: oklch(0.20 0.080 20);
54
+
55
+ /* Remap semantic accent tokens to red */
56
+ --colour-text-accent: var(--red-08);
57
+ --colour-text-eyebrow: var(--red-08);
58
+ --colour-link-default: var(--red-09);
59
+ --colour-link-hover: var(--red-08);
60
+ }
61
+ ```
62
+
63
+ **Example: button overrides only**
64
+
65
+ ```css
66
+ /* app/assets/styles/button-overrides.css */
67
+ :root {
68
+ --theme-button-primary-surface: oklch(0.55 0.20 280);
69
+ --theme-button-primary-surface-hover: oklch(0.48 0.22 280);
70
+ --theme-button-primary-surface-active: oklch(0.42 0.22 280);
71
+ --theme-button-primary-text: oklch(1 0 0);
72
+ --theme-button-primary-text-hover: oklch(1 0 0);
73
+ }
74
+ ```
75
+
76
+ ### 2. Register it in nuxt.config.ts
77
+
78
+ Import your override file **after** the layer styles:
79
+
80
+ ```ts
81
+ export default defineNuxtConfig({
82
+ extends: "srcdev-nuxt-components",
83
+ css: [
84
+ "~/assets/styles/form-overrides.css", // or whichever file(s) you created
85
+ ],
86
+ });
87
+ ```
88
+
89
+ The layer's own CSS loads via the `extends` mechanism before app CSS, so your unlayered overrides
90
+ always win.
91
+
92
+ ### 3. Dark mode (optional)
93
+
94
+ If you need dark mode variants of your overrides, add them in the same file using the `html.dark`
95
+ class selector:
96
+
97
+ ```css
98
+ :root {
99
+ --theme-input-surface: oklch(0.98 0.005 250);
100
+ }
101
+
102
+ :where(html.dark) {
103
+ --theme-input-surface: oklch(0.15 0.02 255);
104
+ }
105
+ ```
106
+
107
+ ## Scoped overrides (section or component level)
108
+
109
+ To restrict an override to a specific section of the page, scope to a wrapper class instead of
110
+ `:root`:
111
+
112
+ ```css
113
+ .pricing-section {
114
+ --theme-button-primary-surface: oklch(0.55 0.20 140); /* green for pricing CTA */
115
+ }
116
+ ```
117
+
118
+ ## Token reference
119
+
120
+ See `CONSUMER-STYLING.md` in the layer package for the full list of available tokens, grouped
121
+ by category (typography, colours, form inputs, buttons, checkboxes, toggle, glass panel,
122
+ StepperList).
123
+
124
+ ## Notes
125
+
126
+ - Only override the tokens you actually need — unset tokens fall back to layer defaults.
127
+ - Use oklch for all colour values. Use an oklch colour picker (e.g. oklch.com) to build scales.
128
+ - For a full palette replacement (replacing the entire default theme), use
129
+ `theming-override-default.md` instead.
130
+ - `--slate-*` neutral tokens come from the layer and do not need to be redefined unless you
131
+ genuinely want different neutrals.
@@ -0,0 +1,74 @@
1
+ # Vue Video Autoplay on Client-Side Navigation
2
+
3
+ ## Overview
4
+
5
+ In Vue/Nuxt SPAs, binding `:src` directly on a `<video>` element silently skips the browser fetch when Vue patches the element during client-side navigation. The video appears but never loads — no network request is made. Use a `<source>` child element instead, combined with `:key` and an explicit `load()` call.
6
+
7
+ ## The problem
8
+
9
+ When Vue patches an existing `<video>` DOM element (e.g. on route change), setting `src` via `:src` doesn't always trigger the browser to re-fetch the media resource. This fails silently — no console error, no network request, poster image just sits there.
10
+
11
+ ## The fix
12
+
13
+ ```vue
14
+ <!-- ✅ Correct — src in <source> child -->
15
+ <video
16
+ :key="src"
17
+ autoplay
18
+ muted
19
+ loop
20
+ playsinline
21
+ preload="auto"
22
+ :poster="poster"
23
+ >
24
+ <source :src="src" type="video/mp4" />
25
+ </video>
26
+
27
+ <!-- ❌ Wrong — :src on <video> silently skips fetch on Vue patches -->
28
+ <video autoplay muted loop playsinline preload="auto" :src="src"></video>
29
+ ```
30
+
31
+ ## Three things working together
32
+
33
+ 1. **`<source>` child** — the browser re-reads source children when `load()` is called, reliably initiating the fetch.
34
+ 2. **`:key="src"`** — forces Vue to unmount/remount the `<video>` element when the source changes (covers same-component src swaps).
35
+ 3. **Explicit `v.load()` call** — programmatically re-initiates fetching after mount and on src change. Do not rely on `autoplay` alone for client-side navigation.
36
+
37
+ ## Autoplay pattern for Nuxt components
38
+
39
+ ```ts
40
+ const videoEl = shallowRef<HTMLVideoElement | null>(null);
41
+
42
+ const tryPlay = async () => {
43
+ const v = videoEl.value;
44
+ if (!v) return;
45
+ v.muted = true; // required for programmatic autoplay in all browsers
46
+ try {
47
+ await v.play();
48
+ } catch {
49
+ // autoplay blocked — poster remains visible
50
+ }
51
+ };
52
+
53
+ const kickOffLoad = async () => {
54
+ await nextTick();
55
+ const v = videoEl.value;
56
+ if (!v) return;
57
+ v.load();
58
+ void tryPlay();
59
+ };
60
+
61
+ // Covers initial mount and src prop changes
62
+ watch(() => props.src, () => void kickOffLoad(), { immediate: true, flush: "post" });
63
+
64
+ // Covers keep-alive re-activation
65
+ onActivated(() => void kickOffLoad());
66
+ ```
67
+
68
+ Attach `@loadeddata="() => void tryPlay()"` and `@canplay="() => void tryPlay()"` to the `<video>` element as additional retry points once data arrives.
69
+
70
+ ## Notes
71
+
72
+ - `preload="auto"` tells the browser to buffer eagerly — essential for autoplay reliability.
73
+ - `v.muted = true` set programmatically (in addition to the `muted` attribute) works around a Safari bug where the attribute alone is insufficient for programmatic play.
74
+ - This pattern applies to any Vue/Nuxt video component, not just background/ambient video.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  Althought this repo is public and feel free to do what you wish with it, this has been developed for use with websites we develop.
10
10
 
11
- ## Install Nuxt Forms layer
11
+ ## Install Nuxt Components layer
12
12
 
13
13
  ```bash
14
14
  npm install --save srcdev-nuxt-components
@@ -17,10 +17,7 @@ npm install --save srcdev-nuxt-components
17
17
  ```ts
18
18
  defineNuxtConfig({
19
19
  extends: "srcdev-nuxt-components",
20
- css: [
21
- "srcdev-nuxt-components/app/assets/styles/main.css",
22
- "./app/assets/styles/main.css",
23
- ],
20
+ css: ["srcdev-nuxt-components/app/assets/styles/main.css", "./app/assets/styles/main.css"],
24
21
  });
25
22
  ```
26
23
 
@@ -44,6 +41,19 @@ npm run setup:claude
44
41
 
45
42
  Skills are copied into `.claude/skills/srcdev-nuxt-components/` so they never conflict with or overwrite skills your own project defines. Re-running the script after a package update is safe.
46
43
 
44
+ ### Automate with postinstall (recommended)
45
+
46
+ To ensure skills are always up to date and `nuxt prepare` is never forgotten, combine both into a `postinstall` script. npm runs this automatically after every `npm install`:
47
+
48
+ ```json
49
+ "scripts": {
50
+ "setup:claude": "cp -r node_modules/srcdev-nuxt-components/.claude/skills .claude/skills/srcdev-nuxt-components",
51
+ "postinstall": "nuxt prepare && npm run setup:claude"
52
+ }
53
+ ```
54
+
55
+ > If your app uses a standalone env flag for `nuxt prepare` (e.g. `NUXT_STANDALONE=true`), include it in the `postinstall` command. This is project-specific — check your own `nuxt.config.ts` to confirm whether it is needed.
56
+
47
57
  ---
48
58
 
49
59
  ## Consumer App Configuration
@@ -0,0 +1,201 @@
1
+ <template>
2
+ <component
3
+ :is="tag"
4
+ class="banner-video"
5
+ :class="elementClasses"
6
+ :style="{
7
+ '--_max-height': maxHeight,
8
+ '--_max-height-tablet': maxHeightTablet,
9
+ '--_max-height-mobile': maxHeightMobile,
10
+ '--_aspect-ratio': aspectRatio,
11
+ '--_align-self': verticalPosition,
12
+ '--_justify-self': horizontalPosition,
13
+ }"
14
+ >
15
+ <video
16
+ :key="src"
17
+ ref="videoEl"
18
+ class="video"
19
+ autoplay
20
+ muted
21
+ loop
22
+ playsinline
23
+ preload="auto"
24
+ :poster="poster"
25
+ :style="{ objectFit: props.objectFit }"
26
+ @loadeddata="handleLoadedData"
27
+ @canplay="handleCanPlay"
28
+ >
29
+ <source :src="src" type="video/mp4" />
30
+ </video>
31
+ <NuxtImg
32
+ class="fallback"
33
+ :src="poster"
34
+ :alt="alt"
35
+ :width="imgWidth"
36
+ :height="imgHeight"
37
+ loading="eager"
38
+ decoding="async"
39
+ :style="{ objectFit: props.objectFit, objectPosition: imgObjectPosition }"
40
+ />
41
+ </component>
42
+ </template>
43
+
44
+ <script setup lang="ts">
45
+ interface Props {
46
+ /** HTML element to render as the root. Defaults to `section` for landmark semantics. */
47
+ tag?: "section" | "div" | "header" | "main" | "article";
48
+ /** Path to the video source file (mp4). */
49
+ 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
+ poster: string;
53
+ alt?: string;
54
+ /** Intrinsic width of the poster image — required for NuxtImg optimisation. */
55
+ imgWidth?: number;
56
+ /** Intrinsic height of the poster image — required for NuxtImg optimisation. */
57
+ imgHeight?: number;
58
+ /** Maximum height at desktop (≥64em). Defaults to `"56rem"`. */
59
+ maxHeight?: string;
60
+ /** Maximum height at tablet (48em–64em). Falls back to `maxHeight` when unset. */
61
+ maxHeightTablet?: string;
62
+ /** Maximum height on mobile (<48em). Falls back to `maxHeightTablet` then `maxHeight` when unset. */
63
+ maxHeightMobile?: string;
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
+ aspectRatio?: string;
69
+ /** How the video and fallback image fill the banner frame. Defaults to `"cover"`. */
70
+ 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
+ verticalPosition?: "start" | "center" | "end";
73
+ /** Horizontal crop position within the banner. Maps to `object-position` on the fallback image. Defaults to `"center"`. */
74
+ horizontalPosition?: "start" | "center" | "end";
75
+ styleClassPassthrough?: string | string[];
76
+ }
77
+
78
+ const props = withDefaults(defineProps<Props>(), {
79
+ tag: "section",
80
+ alt: "",
81
+ imgWidth: 1920,
82
+ imgHeight: 1080,
83
+ maxHeight: "56rem",
84
+ maxHeightTablet: undefined,
85
+ maxHeightMobile: undefined,
86
+ aspectRatio: "21/9",
87
+ objectFit: "cover",
88
+ verticalPosition: "center",
89
+ horizontalPosition: "center",
90
+ styleClassPassthrough: () => [],
91
+ });
92
+
93
+ const positionKeywordMap = {
94
+ vertical: { start: "top", center: "center", end: "bottom" },
95
+ horizontal: { start: "left", center: "center", end: "right" },
96
+ } as const;
97
+
98
+ const imgObjectPosition = computed(
99
+ () =>
100
+ `${positionKeywordMap.horizontal[props.horizontalPosition]} ${positionKeywordMap.vertical[props.verticalPosition]}`
101
+ );
102
+
103
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
104
+
105
+ watch(
106
+ () => props.styleClassPassthrough,
107
+ () => resetElementClasses(props.styleClassPassthrough)
108
+ );
109
+
110
+ const videoEl = shallowRef<HTMLVideoElement | null>(null);
111
+
112
+ const tryPlay = async () => {
113
+ const v = videoEl.value;
114
+ if (!v) return;
115
+ try {
116
+ // Ensure muted stays true — required for programmatic autoplay in all browsers
117
+ v.muted = true;
118
+ await v.play();
119
+ } catch {
120
+ // Autoplay blocked or interrupted — fallback image will remain visible
121
+ }
122
+ };
123
+
124
+ const kickOffLoad = async () => {
125
+ await nextTick();
126
+ const v = videoEl.value;
127
+ if (!v) return;
128
+ // Force the media element to (re)read its source child and begin fetching
129
+ v.load();
130
+ // Attempt immediate play; loadeddata/canplay handlers will retry once data arrives
131
+ void tryPlay();
132
+ };
133
+
134
+ const handleLoadedData = () => {
135
+ void tryPlay();
136
+ };
137
+ const handleCanPlay = () => {
138
+ void tryPlay();
139
+ };
140
+
141
+ // Runs on mount AND whenever src changes (covers route-change re-use edge cases)
142
+ watch(
143
+ () => props.src,
144
+ () => {
145
+ void kickOffLoad();
146
+ },
147
+ { immediate: true, flush: "post" }
148
+ );
149
+
150
+ // Extra safety: when the component becomes active again (e.g. returning via keep-alive)
151
+ onActivated(() => {
152
+ void kickOffLoad();
153
+ });
154
+ </script>
155
+
156
+ <style lang="css">
157
+ @layer components {
158
+ .banner-video {
159
+ display: grid;
160
+ grid-template-areas: "media";
161
+ aspect-ratio: var(--_aspect-ratio, 16/9);
162
+ max-height: var(--_max-height-mobile, var(--_max-height, 56rem));
163
+ width: 100%;
164
+ overflow: hidden;
165
+
166
+ @media (min-width: 48em) {
167
+ max-height: var(--_max-height-tablet, var(--_max-height, 56rem));
168
+ }
169
+
170
+ @media (min-width: 64em) {
171
+ max-height: var(--_max-height, 56rem);
172
+ }
173
+
174
+ .video {
175
+ grid-area: media;
176
+ display: block;
177
+ width: 100%;
178
+ height: auto;
179
+ min-height: 100%;
180
+ align-self: var(--_align-self, center);
181
+ justify-self: var(--_justify-self, center);
182
+ }
183
+
184
+ .fallback {
185
+ grid-area: media;
186
+ display: none;
187
+ width: 100%;
188
+ height: 100%;
189
+ }
190
+
191
+ @media (prefers-reduced-motion: reduce) {
192
+ .video {
193
+ display: none;
194
+ }
195
+ .fallback {
196
+ display: block;
197
+ }
198
+ }
199
+ }
200
+ }
201
+ </style>
@@ -0,0 +1,191 @@
1
+ import BannerVideo from "../BannerVideo.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+
4
+ const meta: Meta<typeof BannerVideo> = {
5
+ title: "Atoms/Media/BannerVideo",
6
+ component: BannerVideo,
7
+ argTypes: {
8
+ src: {
9
+ control: "text",
10
+ description: "Path to the mp4 video source file",
11
+ table: { category: "Media" },
12
+ },
13
+ poster: {
14
+ control: "text",
15
+ description: "Path to the fallback/poster image — shown before video loads, on error, and when prefers-reduced-motion is active",
16
+ table: { category: "Media" },
17
+ },
18
+ alt: {
19
+ control: "text",
20
+ description: "Alt text for the fallback image",
21
+ table: { category: "Media" },
22
+ },
23
+ imgWidth: {
24
+ control: { type: "number" },
25
+ description: "Intrinsic width of the poster image — required for NuxtImg optimisation",
26
+ table: { category: "Media" },
27
+ },
28
+ imgHeight: {
29
+ control: { type: "number" },
30
+ description: "Intrinsic height of the poster image — required for NuxtImg optimisation",
31
+ table: { category: "Media" },
32
+ },
33
+ tag: {
34
+ control: "select",
35
+ options: ["section", "div", "header", "main", "article"],
36
+ description: "HTML element rendered as the root",
37
+ table: { category: "Markup" },
38
+ },
39
+ maxHeight: {
40
+ control: "text",
41
+ description: "Maximum height at desktop (≥64em). Defaults to 56rem",
42
+ table: { category: "Layout" },
43
+ },
44
+ maxHeightTablet: {
45
+ control: "text",
46
+ description: "Maximum height at tablet (48em–64em). Falls back to maxHeight when unset",
47
+ table: { category: "Layout" },
48
+ },
49
+ maxHeightMobile: {
50
+ control: "text",
51
+ description: "Maximum height on mobile (<48em). Falls back through tablet → desktop when unset",
52
+ table: { category: "Layout" },
53
+ },
54
+ aspectRatio: {
55
+ control: "text",
56
+ description: "CSS aspect-ratio of the banner container (e.g. '16/9', '21/9', '4/3')",
57
+ table: { category: "Layout" },
58
+ },
59
+ objectFit: {
60
+ control: "select",
61
+ options: ["cover", "contain", "fill", "none", "scale-down"],
62
+ description: "How the video and fallback image fill the banner frame",
63
+ table: { category: "Appearance" },
64
+ },
65
+ verticalPosition: {
66
+ control: "select",
67
+ options: ["start", "center", "end"],
68
+ description: "Vertical crop position — start (top), center, or end (bottom). Maps to align-self on the video and object-position on the fallback image",
69
+ table: { category: "Appearance" },
70
+ },
71
+ horizontalPosition: {
72
+ control: "select",
73
+ options: ["start", "center", "end"],
74
+ description: "Horizontal crop position — start (left), center, or end (right). Maps to object-position on the fallback image",
75
+ table: { category: "Appearance" },
76
+ },
77
+ styleClassPassthrough: {
78
+ table: { disable: true },
79
+ },
80
+ },
81
+ parameters: {
82
+ docs: {
83
+ description: {
84
+ component:
85
+ "A full-width banner that plays a muted, looping mp4 video. The poster image is shown before the video loads, when the video fails to play, and whenever the user has `prefers-reduced-motion: reduce` set. The banner is sized via `aspect-ratio` with per-breakpoint `max-height` caps — no fixed pixel heights. `objectFit` controls how media fills the frame. `verticalPosition` and `horizontalPosition` control the crop focal point — the video uses CSS grid alignment (`align-self`/`justify-self`) while the fallback image uses `object-position`.",
86
+ },
87
+ },
88
+ },
89
+ };
90
+
91
+ export default meta;
92
+ type Story = StoryObj<typeof BannerVideo>;
93
+
94
+ export const Default: Story = {
95
+ args: {
96
+ src: "/images/banners/video/lake-banner.mp4",
97
+ poster: "/images/banners/video/lake-banner.jpg",
98
+ alt: "A serene lake landscape",
99
+ imgWidth: 1920,
100
+ imgHeight: 1080,
101
+ tag: "section",
102
+ maxHeight: "56rem",
103
+ aspectRatio: "21/9",
104
+ objectFit: "cover",
105
+ verticalPosition: "center",
106
+ horizontalPosition: "center",
107
+ },
108
+ parameters: {
109
+ docs: {
110
+ description: {
111
+ story: "Default props. The lake video plays muted and looped at a 21/9 aspect ratio, capped at 56rem on desktop.",
112
+ },
113
+ },
114
+ },
115
+ };
116
+
117
+ export const Widescreen: Story = {
118
+ args: {
119
+ ...Default.args,
120
+ aspectRatio: "21/9",
121
+ maxHeight: "80rem",
122
+ },
123
+ parameters: {
124
+ docs: {
125
+ description: {
126
+ story: "A wider cinematic crop with a raised maxHeight, giving the banner more vertical presence on large screens.",
127
+ },
128
+ },
129
+ },
130
+ };
131
+
132
+ export const Standard169: Story = {
133
+ args: {
134
+ ...Default.args,
135
+ aspectRatio: "16/9",
136
+ maxHeight: "56rem",
137
+ },
138
+ parameters: {
139
+ docs: {
140
+ description: {
141
+ story: "Standard 16/9 aspect ratio — matches a typical video's native ratio, so objectPosition has minimal visible effect unless the frame is very narrow.",
142
+ },
143
+ },
144
+ },
145
+ };
146
+
147
+ export const ShortBanner: Story = {
148
+ args: {
149
+ ...Default.args,
150
+ maxHeight: "32rem",
151
+ verticalPosition: "end",
152
+ },
153
+ parameters: {
154
+ docs: {
155
+ description: {
156
+ story: "A shallower banner capped at 32rem. verticalPosition: 'end' shifts the crop to show the bottom of the frame.",
157
+ },
158
+ },
159
+ },
160
+ };
161
+
162
+ export const FullViewportHeight: Story = {
163
+ args: {
164
+ ...Default.args,
165
+ maxHeight: "100vh",
166
+ aspectRatio: "16/9",
167
+ },
168
+ parameters: {
169
+ docs: {
170
+ description: {
171
+ story: "maxHeight set to 100vh — the banner fills the full viewport height on large screens and aspect-ratio controls height on smaller ones.",
172
+ },
173
+ },
174
+ },
175
+ };
176
+
177
+ export const ResponsiveBreakpoints: Story = {
178
+ args: {
179
+ ...Default.args,
180
+ maxHeight: "56rem",
181
+ maxHeightTablet: "40rem",
182
+ maxHeightMobile: "24rem",
183
+ },
184
+ parameters: {
185
+ docs: {
186
+ description: {
187
+ story: "Per-breakpoint max-height: 56rem desktop, 40rem tablet (≥48em), 24rem mobile (<48em). Resize the viewport to see each cap take effect.",
188
+ },
189
+ },
190
+ },
191
+ };