srcdev-nuxt-components 9.5.2 → 9.5.4

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 (76) hide show
  1. package/.claude/skills/colour-scheme-disable.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +214 -0
  3. package/.claude/skills/component-inline-action-button.md +2 -2
  4. package/.claude/skills/components/canvas-switcher.md +5 -1
  5. package/.claude/skills/components/carousel-flip.md +3 -3
  6. package/.claude/skills/components/clipped-panel.md +2 -2
  7. package/.claude/skills/components/dashboard-quad-grid.md +2 -2
  8. package/.claude/skills/components/dashboard-stats-grid.md +1 -1
  9. package/.claude/skills/components/deep-expanding-menu-classic.md +1 -1
  10. package/.claude/skills/components/deep-expanding-menu.md +1 -1
  11. package/.claude/skills/components/eyebrow-text.md +6 -4
  12. package/.claude/skills/components/google-reviews.md +193 -0
  13. package/.claude/skills/components/input-number.md +4 -0
  14. package/.claude/skills/components/input-otp.md +120 -0
  15. package/.claude/skills/components/section-parallax.md +1 -1
  16. package/.claude/skills/components/select-menu.md +9 -5
  17. package/.claude/skills/components/services-card.md +25 -2
  18. package/.claude/skills/composable-colour-scheme.md +2 -0
  19. package/.claude/skills/index.md +5 -1
  20. package/.claude/skills/new-app-scaffold.md +1 -1
  21. package/.claude/skills/storybook-add-story.md +5 -0
  22. package/.claude/skills/theming-colour-ramps.md +6 -33
  23. package/.claude/skills/theming-component-token-pattern.md +30 -0
  24. package/.claude/skills/theming-dark-mode.md +243 -0
  25. package/.claude/skills/theming-override-default.md +6 -7
  26. package/.claude/skills/theming-partial-override.md +11 -11
  27. package/.vscode/srcdev-component-eyebrow-text.code-snippets +4 -0
  28. package/.vscode/srcdev-component-google-reviews.code-snippets +78 -0
  29. package/.vscode/srcdev-component-input-otp.code-snippets +47 -0
  30. package/.vscode/srcdev-component-select-menu.code-snippets +13 -0
  31. package/.vscode/srcdev-component-services-card.code-snippets +38 -7
  32. package/README.md +81 -41
  33. package/app/assets/styles/setup/01.config/_head.css +0 -9
  34. package/app/assets/styles/setup/03.theming/_default.css +29 -29
  35. package/app/assets/styles/setup/03.theming/_error.css +3 -3
  36. package/app/assets/styles/setup/03.theming/_theme-slots.css +13 -16
  37. package/app/assets/styles/setup/a11y/_variables.css +2 -2
  38. package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +47 -13
  39. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +5 -1
  40. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +4 -1
  41. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +7 -7
  42. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +10 -0
  43. package/app/components/02.molecules/select-menu/SelectMenu.vue +15 -1
  44. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +35 -0
  45. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +9 -0
  46. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +3 -3
  47. package/app/components/03.organisms/google-reviews/CONSUMER-STYLING.md +110 -0
  48. package/app/components/03.organisms/google-reviews/GoogleReviewCard.vue +161 -0
  49. package/app/components/03.organisms/google-reviews/GoogleReviews.vue +275 -0
  50. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +323 -0
  51. package/app/components/03.organisms/google-reviews/tests/GoogleReviewCard.spec.ts +104 -0
  52. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +232 -0
  53. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +9 -0
  54. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +43 -0
  55. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +28 -3
  56. package/app/components/03.organisms/services/services-card/ServicesCard.vue +111 -18
  57. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +122 -0
  58. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +4 -1
  59. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +100 -19
  60. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +12 -3
  61. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +1 -1
  62. package/app/components/05.forms/input-number/InputNumber.vue +2 -2
  63. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +59 -0
  64. package/app/components/05.forms/input-otp/InputOtp.vue +254 -0
  65. package/app/components/05.forms/input-otp/InputOtpField.vue +106 -0
  66. package/app/components/05.forms/input-otp/stories/InputOtp.stories.ts +100 -0
  67. package/app/components/05.forms/input-otp/stories/InputOtpField.stories.ts +163 -0
  68. package/app/components/05.forms/input-otp/tests/InputOtp.spec.ts +275 -0
  69. package/app/components/05.forms/input-otp/tests/InputOtpField.spec.ts +76 -0
  70. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +151 -33
  71. package/app/composables/tests/useAriaDescribedById.spec.ts +5 -5
  72. package/app/composables/useGoogleReviews.ts +4 -0
  73. package/app/types/components/google-reviews.d.ts +18 -0
  74. package/app/types/components/index.ts +1 -0
  75. package/nuxt.config.ts +9 -9
  76. package/package.json +9 -8
@@ -0,0 +1,43 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`GoogleReviews > renders correct HTML structure 1`] = `
4
+ "<section class="google-reviews" aria-label="Google reviews">
5
+ <div class="google-reviews-header">
6
+ <div class="google-reviews-summary"><span class="google-reviews-summary-rating" aria-hidden="true">4.7</span><span class="google-reviews-stars" style="--_rating: 4.66;" aria-hidden="true">★★★★★</span><span class="sr-only">Rated 4.7 out of 5</span><a href="https://maps.google.com/?cid=1" class="google-reviews-summary-link">128 reviews on Google</a></div>
7
+ </div>
8
+ <!--v-if-->
9
+ <ul class="google-reviews-list" tabindex="0">
10
+ <li class="google-reviews-item">
11
+ <article class="google-review-card">
12
+ <div class="google-review-card-author"><span class="display-avatar md" aria-hidden="true"><span>R1</span>
13
+ <!--v-if--></span><a id="v-0-0-0" href="https://maps.google.com/contrib/1" class="google-review-card-author-name">Reviewer 1</a><time datetime="2026-09-18T10:00:00Z" class="google-review-card-time">1 weeks ago</time>
14
+ </div>
15
+ <div class="google-review-card-rating"><span class="google-review-card-stars" style="--_rating: 5;" aria-hidden="true">★★★★★</span><span class="sr-only">Rated 5 out of 5</span></div>
16
+ <p class="google-review-card-text">Review text 1</p><a href="https://maps.google.com/review/1" aria-describedby="v-0-0-0" class="google-review-card-link">Read on Google</a>
17
+ </article>
18
+ </li>
19
+ <li class="google-reviews-item">
20
+ <article class="google-review-card">
21
+ <div class="google-review-card-author"><span class="display-avatar md" aria-hidden="true"><span>R2</span>
22
+ <!--v-if--></span><a id="v-0-0-1" href="https://maps.google.com/contrib/2" class="google-review-card-author-name">Reviewer 2</a><time datetime="2026-09-18T10:00:00Z" class="google-review-card-time">2 weeks ago</time>
23
+ </div>
24
+ <div class="google-review-card-rating"><span class="google-review-card-stars" style="--_rating: 4;" aria-hidden="true">★★★★★</span><span class="sr-only">Rated 4 out of 5</span></div>
25
+ <p class="google-review-card-text">Review text 2</p><a href="https://maps.google.com/review/2" aria-describedby="v-0-0-1" class="google-review-card-link">Read on Google</a>
26
+ </article>
27
+ </li>
28
+ <li class="google-reviews-item">
29
+ <article class="google-review-card">
30
+ <div class="google-review-card-author"><span class="display-avatar md" aria-hidden="true"><span>R3</span>
31
+ <!--v-if--></span><a id="v-0-0-2" href="https://maps.google.com/contrib/3" class="google-review-card-author-name">Reviewer 3</a><time datetime="2026-09-18T10:00:00Z" class="google-review-card-time">3 weeks ago</time>
32
+ </div>
33
+ <div class="google-review-card-rating"><span class="google-review-card-stars" style="--_rating: 3;" aria-hidden="true">★★★★★</span><span class="sr-only">Rated 3 out of 5</span></div>
34
+ <p class="google-review-card-text">Review text 3</p><a href="https://maps.google.com/review/3" aria-describedby="v-0-0-2" class="google-review-card-link">Read on Google</a>
35
+ </article>
36
+ </li>
37
+ </ul>
38
+ <div class="google-reviews-footer">
39
+ <!--v-if-->
40
+ <p class="google-reviews-attribution">Reviews from Google Maps</p>
41
+ </div>
42
+ </section>"
43
+ `;
@@ -3,8 +3,8 @@
3
3
  ## Public token API
4
4
 
5
5
  Every token below is consumed directly at its point of use (`var(--services-card-gap, 1rem)`,
6
- etc.) — there is no `--_`-prefixed private indirection layer, since none of these values are
7
- reused across multiple declarations or computed from something else. Set the token globally in
6
+ etc.). The only private token is `--_scrim-colour`, which resolves
7
+ `--image-wrapper-details-scrim-colour` once so the scrim and the text colour read the same value. Set the token globally in
8
8
  a theme file, scoped to a page wrapper, or per-instance via `styleClassPassthrough` — no
9
9
  `:deep()` needed, component styles are unscoped.
10
10
 
@@ -25,6 +25,8 @@ i.e. `is-clickable` is on the root — since these all default to `transparent`/
25
25
  | `--services-card-outline-colour-hover` | `transparent` | Outline colour on `:hover`/`:focus-visible` |
26
26
  | `--services-card-outline-offset` | `0px` | Resting outline offset |
27
27
  | `--services-card-outline-offset-hover` | `0px` | Outline offset on `:hover`/`:focus-visible` — e.g. set to a larger value than the resting offset for an outline that grows outward on hover |
28
+ | `--services-card-background-color-hover` | resting background colour | Background on `:hover`/`:focus-visible` |
29
+ | `--services-card-transform-hover` | `none` | Transform on `:hover`/`:focus-visible`, e.g. `translateY(-0.4rem)` for a lift. Transitions with the other states; skipped under `prefers-reduced-motion: reduce` |
28
30
 
29
31
  ```css
30
32
  /* A card that gets a visible border + outline ring when it's a whole-card link */
@@ -40,11 +42,14 @@ i.e. `is-clickable` is on the root — since these all default to `transparent`/
40
42
  | Token | Default | Controls |
41
43
  |---|---|---|
42
44
  | `--services-card-gap` | `1rem` | Gap between the image row and the details row |
45
+ | `--services-card-background-color` | `transparent` | Card background |
46
+ | `--services-card-padding-block` | none | Padding on the card root (block axis) |
47
+ | `--services-card-padding-inline` | `0` | Padding on the card root (inline axis). Leave at `0` for a flush image, and pad `--details-wrapper-padding-*` instead |
43
48
  | `--image-wrapper-aspect-ratio` | `3/4` | Image aspect ratio |
44
49
  | `--image-wrapper-border-radius` | `8px` | Image corner radius |
45
50
  | `--image-wrapper-padding-block` | `0 0` | Padding inside the image wrapper (block axis) |
46
51
  | `--image-wrapper-padding-inline` | `0 0` | Padding inside the image wrapper (inline axis) |
47
- | `--image-wrapper-border-image-zoom-transform` | `scale(1.05)` | Transform applied to the image on hover |
52
+ | `--image-wrapper-border-image-zoom-transform` | `scale(1.05)` | Transform applied to the image while the pointer is over the image. Set `none` to turn the zoom off (not `scale(0)`, which hides the image). Turning it off suits a whole-card link with its own hover effect (`--services-card-transform-hover`, border/background hover tokens): otherwise moving from the card body onto the image starts a second, separate effect |
48
53
  | `--details-wrapper-grid-gap` | `1rem` | Gap between eyebrow/title/description/footer |
49
54
  | `--details-wrapper-padding-block` | `0` | Padding inside the details wrapper (block axis) |
50
55
  | `--details-wrapper-padding-inline` | `0` | Padding inside the details wrapper (inline axis) |
@@ -53,15 +58,35 @@ i.e. `is-clickable` is on the root — since these all default to `transparent`/
53
58
  | `--description-padding-block` | `0 0` | Padding on the description text |
54
59
  | `--description-text-colour` | `var(--colour-text-secondary)` | Description paragraph colour |
55
60
  | `--description-line-height` | `1.4` | Description line height |
61
+ | `--description-font-weight` | `inherit` | Description font weight |
56
62
  | `--description-line-clamp` | `100` | Number of lines the description clamps to — default is effectively unclamped; set e.g. `3` to clamp |
57
63
  | `--meta-padding-block` | `1.6rem 0` | Padding above/below the meta row's divider line |
58
64
  | `--meta-border-colour` | `var(--theme-border)` | Meta row's top divider colour |
59
65
  | `--meta-text-colour` | `inherit` | Duration/price text colour |
66
+ | `--meta-duration-text-colour` | meta text colour | Duration colour only |
67
+ | `--meta-price-text-colour` | meta text colour | Price colour only, e.g. brighter than the duration |
68
+ | `--meta-duration-font-weight` | `500` | Duration font weight |
69
+ | `--meta-price-font-weight` | `700` | Price font weight |
70
+ | `--meta-letter-spacing` | `inherit` | Letter-spacing on the meta row |
60
71
  | `--meta-font-size` | `1.4rem` | Duration/price text size |
61
72
  | `--meta-text-transform` | `uppercase` | Text transform on the duration/price meta row |
62
73
  | `--footer-padding-block` | `0` | Padding on the `.footer` wrapper (meta row + actions slot) |
63
74
  | `--footer-wrapper-grid-gap` | `1rem` | Gap between the meta row and the actions slot inside `.footer` |
64
75
 
76
+ ### Titles over the image (`titlesWithinImageWrapper`)
77
+
78
+ | Token | Default | Controls |
79
+ |---|---|---|
80
+ | `--image-wrapper-details-scrim-colour` | `#000` | Colour of the gradient scrim behind the title/eyebrow. Use an opaque colour: the default scrim fades it with `color-mix()`, and the text colour is derived from it |
81
+ | `--image-wrapper-details-scrim` | `linear-gradient(to top, <scrim colour at 75%>, transparent 70%)` | The whole scrim `background` value — replace it for a different shape or direction, or set `none` to remove it |
82
+ | `--image-wrapper-details-text-colour` | `contrast-color(<scrim colour>)`, `#fff` without support | Title/eyebrow text colour. Where `contrast-color()` is supported it picks black or white against the scrim colour, so a light scrim gets dark text automatically |
83
+ | `--image-wrapper-details-eyebrow-text-colour` | `inherit` | Eyebrow colour. The eyebrow's gradient fill is turned off over the image so it can't clash with the photo |
84
+ | `--image-wrapper-details-gap` | `0.5rem` | Gap between eyebrow and title |
85
+ | `--image-wrapper-details-eyebrow-text-padding-block` / `-inline` | `0` / `2.2rem` | Eyebrow padding |
86
+ | `--image-wrapper-details-hero-text-padding-block` / `-inline` | `1.2rem 2.2rem` / `2.2rem` | Title padding |
87
+
88
+ `contrast-color()` only reads the scrim colour, not the photo. If you remove the scrim, set `--image-wrapper-details-text-colour` yourself to suit the images.
89
+
65
90
  ```css
66
91
  /* assets/styles/setup/07.components/services-card.css */
67
92
  :root {
@@ -5,16 +5,40 @@
5
5
  class="services-card"
6
6
  :class="[elementClasses, { 'is-clickable': isClickable() }]"
7
7
  >
8
- <div class="image-wrapper">
9
- <NuxtImg :src="serviceData.image" :alt="serviceData.title" loading="lazy" class="image" />
10
- </div>
8
+ <GridStack tag="div" :style-class-passthrough="['image-wrapper']">
9
+ <template #layer-1>
10
+ <NuxtImg :src="serviceData.image" :alt="serviceData.title" loading="lazy" class="image" />
11
+ </template>
12
+ <template #layer-2>
13
+ <div v-if="props.titlesWithinImageWrapper" class="image-wrapper-details">
14
+ <EyebrowText
15
+ :font-size="eyebrowConfig.fontSize ?? 'medium'"
16
+ :tag="eyebrowConfig.tag ?? 'div'"
17
+ :text-content="serviceData.subtitle"
18
+ />
19
+ <HeroText
20
+ :tag="heroConfig.tag ?? 'h2'"
21
+ :font-size="heroConfig.fontSize ?? 'heading'"
22
+ :text-content="[
23
+ {
24
+ text: serviceData.title,
25
+ styleClass: 'normal',
26
+ },
27
+ ]"
28
+ />
29
+ </div>
30
+ </template>
31
+ </GridStack>
32
+
11
33
  <div class="details-wrapper">
12
34
  <EyebrowText
35
+ v-if="!props.titlesWithinImageWrapper"
13
36
  :font-size="eyebrowConfig.fontSize ?? 'large'"
14
37
  :tag="eyebrowConfig.tag ?? 'div'"
15
38
  :text-content="serviceData.subtitle"
16
39
  />
17
40
  <HeroText
41
+ v-if="!props.titlesWithinImageWrapper"
18
42
  :tag="heroConfig.tag ?? 'h2'"
19
43
  :font-size="heroConfig.fontSize ?? 'heading'"
20
44
  :text-content="[
@@ -50,6 +74,7 @@ interface Props {
50
74
  tag?: "div" | "section" | "article";
51
75
  serviceData: Service;
52
76
  href?: string;
77
+ titlesWithinImageWrapper?: boolean;
53
78
  /**
54
79
  * Force a real browser navigation instead of client-side routing, even for a same-origin
55
80
  * href starting with "/". See InputButton's `external` prop for the full rationale.
@@ -67,6 +92,7 @@ interface Props {
67
92
  const props = withDefaults(defineProps<Props>(), {
68
93
  tag: "div",
69
94
  href: undefined,
95
+ titlesWithinImageWrapper: false,
70
96
  external: false,
71
97
  eyebrowConfig: () => ({}),
72
98
  heroConfig: () => ({}),
@@ -114,6 +140,9 @@ watch(
114
140
  gap: var(--services-card-gap, 1rem);
115
141
  min-inline-size: 0;
116
142
 
143
+ background-color: var(--services-card-background-color, transparent);
144
+ padding-block: var(--services-card-padding-block,);
145
+ padding-inline: var(--services-card-padding-inline, 0);
117
146
  border-radius: var(--services-card-border-radius, 0);
118
147
  border: var(--services-card-border-width, 1px) solid var(--services-card-border-colour, transparent);
119
148
  outline: var(--services-card-outline-width, 2px) solid var(--services-card-outline-colour, transparent);
@@ -121,9 +150,11 @@ watch(
121
150
  overflow: hidden;
122
151
 
123
152
  transition:
153
+ background-color 0.3s ease-in-out,
124
154
  border-color 0.3s ease-in-out,
125
155
  outline-color 0.3s ease-in-out,
126
- outline-offset 0.3s ease-in-out;
156
+ outline-offset 0.3s ease-in-out,
157
+ transform 0.3s ease-in-out;
127
158
 
128
159
  &.is-clickable {
129
160
  color: inherit;
@@ -134,9 +165,21 @@ watch(
134
165
  &:focus-visible {
135
166
  /* Falls back to the resting border colour (not transparent) so setting only
136
167
  --services-card-border-colour doesn't make the border vanish on hover/focus. */
168
+ background-color: var(
169
+ --services-card-background-color-hover,
170
+ var(--services-card-background-color, transparent)
171
+ );
137
172
  border-color: var(--services-card-border-colour-hover, var(--services-card-border-colour, transparent));
138
173
  outline-color: var(--services-card-outline-colour-hover, transparent);
139
174
  outline-offset: var(--services-card-outline-offset-hover, 0px);
175
+ transform: var(--services-card-transform-hover, none);
176
+ }
177
+
178
+ @media (prefers-reduced-motion: reduce) {
179
+ &:hover,
180
+ &:focus-visible {
181
+ transform: none;
182
+ }
140
183
  }
141
184
  }
142
185
 
@@ -144,6 +187,7 @@ watch(
144
187
  aspect-ratio: var(--image-wrapper-aspect-ratio, 3/4);
145
188
  border-radius: var(--image-wrapper-border-radius, 8px);
146
189
  overflow: hidden;
190
+ isolation: isolate;
147
191
  min-inline-size: 0;
148
192
  padding-block: var(--image-wrapper-padding-block, 0 0);
149
193
  padding-inline: var(--image-wrapper-padding-inline, 0 0);
@@ -154,9 +198,46 @@ watch(
154
198
  width: 100%;
155
199
  height: 100%;
156
200
  transition: transform 0.3s ease-in-out;
201
+ }
202
+
203
+ &:hover .image {
204
+ transform: var(--image-wrapper-border-image-zoom-transform, scale(1.05));
205
+ }
206
+
207
+ .image-wrapper-details {
208
+ --_scrim-colour: var(--image-wrapper-details-scrim-colour, #000);
209
+
210
+ position: relative;
211
+ z-index: 1;
212
+ display: grid;
213
+ grid-auto-flow: row;
214
+ align-content: end;
215
+ gap: var(--image-wrapper-details-gap, 0.5rem);
216
+ height: 100%;
217
+ color: var(--image-wrapper-details-text-colour, #fff);
218
+ background: var(--image-wrapper-details-scrim, linear-gradient(to top, rgb(0 0 0 / 0.75), transparent 70%));
157
219
 
158
- &:hover {
159
- transform: var(--image-wrapper-border-image-zoom-transform, scale(1.05));
220
+ @supports (color: color-mix(in oklab, red, transparent)) {
221
+ background: var(
222
+ --image-wrapper-details-scrim,
223
+ linear-gradient(to top, color-mix(in oklab, var(--_scrim-colour) 75%, transparent), transparent 70%)
224
+ );
225
+ }
226
+
227
+ @supports (color: contrast-color(red)) {
228
+ color: var(--image-wrapper-details-text-colour, contrast-color(var(--_scrim-colour)));
229
+ }
230
+
231
+ .eyebrow-text {
232
+ --eyebrow-text-bg-img: none;
233
+ color: var(--image-wrapper-details-eyebrow-text-colour, inherit);
234
+ padding-block: var(--image-wrapper-details-eyebrow-text-padding-block, 0);
235
+ padding-inline: var(--image-wrapper-details-eyebrow-text-padding-inline, 2.2rem);
236
+ }
237
+
238
+ .hero-text {
239
+ padding-block: var(--image-wrapper-details-hero-text-padding-block, 1.2rem 2.2rem);
240
+ padding-inline: var(--image-wrapper-details-hero-text-padding-inline, 2.2rem);
160
241
  }
161
242
  }
162
243
  }
@@ -175,11 +256,12 @@ watch(
175
256
  }
176
257
 
177
258
  .hero-text {
178
- padding-block: var(--hero-text-padding-block, 2rem 1rem);
259
+ padding-block: var(--hero-text-padding-block, 1.2rem 1rem);
179
260
  }
180
261
 
181
262
  .description {
182
263
  color: var(--description-text-colour, var(--colour-text-secondary));
264
+ font-weight: var(--description-font-weight, inherit);
183
265
  line-height: var(--description-line-height, 1.4);
184
266
  padding-block: var(--description-padding-block, 0 0);
185
267
 
@@ -198,18 +280,29 @@ watch(
198
280
  margin-block-start: auto;
199
281
  min-inline-size: 0;
200
282
  padding-block: var(--footer-padding-block, 0);
201
- }
202
283
 
203
- .meta {
204
- display: flex;
205
- align-items: center;
206
- justify-content: space-between;
207
- gap: 1rem;
208
- padding-block: var(--meta-padding-block, 1.6rem 0);
209
- border-block-start: 1px solid var(--meta-border-colour, var(--theme-border));
210
- color: var(--meta-text-colour, inherit);
211
- font-size: var(--meta-font-size, 1.4rem);
212
- text-transform: var(--meta-text-transform, uppercase);
284
+ .meta {
285
+ display: flex;
286
+ align-items: center;
287
+ justify-content: space-between;
288
+ gap: 1rem;
289
+ padding-block: var(--meta-padding-block, 1.6rem 0);
290
+ border-block-start: 1px solid var(--meta-border-colour, var(--theme-border));
291
+ color: var(--meta-text-colour, inherit);
292
+ font-size: var(--meta-font-size, 1.4rem);
293
+ letter-spacing: var(--meta-letter-spacing, inherit);
294
+ text-transform: var(--meta-text-transform, uppercase);
295
+
296
+ .meta-duration {
297
+ color: var(--meta-duration-text-colour, inherit);
298
+ font-weight: var(--meta-duration-font-weight, 500);
299
+ }
300
+
301
+ .meta-price {
302
+ color: var(--meta-price-text-colour, inherit);
303
+ font-weight: var(--meta-price-font-weight, 700);
304
+ }
305
+ }
213
306
  }
214
307
  }
215
308
  }
@@ -257,3 +257,125 @@ export const DesignReplica: Story = {
257
257
  },
258
258
  },
259
259
  };
260
+
261
+ const makeOverlayService = (
262
+ slug: string,
263
+ subtitle: string,
264
+ title: string,
265
+ shortDescription: string,
266
+ duration: string,
267
+ price: string
268
+ ): Service => ({
269
+ ...balayageService,
270
+ slug,
271
+ subtitle,
272
+ title,
273
+ shortDescription,
274
+ duration,
275
+ price,
276
+ image: `https://picsum.photos/seed/overlay-${slug}/600/800`,
277
+ });
278
+
279
+ const overlayServices: Service[] = [
280
+ makeOverlayService(
281
+ "balayage",
282
+ "Hair Colouring",
283
+ "Balayage",
284
+ "Hand-painted, sun-kissed colour with a soft regrowth line and low upkeep.",
285
+ "2.5 - 3.5 hours",
286
+ "From £95"
287
+ ),
288
+ makeOverlayService(
289
+ "full-colour",
290
+ "Hair Colouring",
291
+ "Full Colour",
292
+ "Complete colour coverage, from root touch-ups and natural tones to bold, fashion-forward shades.",
293
+ "1.5 - 2.5 hours",
294
+ "From £75"
295
+ ),
296
+ makeOverlayService(
297
+ "restyle",
298
+ "Cutting & Treatment",
299
+ "Restyle",
300
+ "A transformative cut that takes your hair in a new direction: a new shape, length, or style.",
301
+ "1 - 1.5 hours",
302
+ "£65"
303
+ ),
304
+ makeOverlayService(
305
+ "keratin-treatment",
306
+ "Cutting & Treatment",
307
+ "Keratin Treatment",
308
+ "Frizz-free smoothing sealed into the hair, with shine that lasts for months.",
309
+ "2 - 3 hours",
310
+ "£120"
311
+ ),
312
+ ];
313
+
314
+ export const DarkOverlayCards: Story = {
315
+ name: "Dark Overlay Cards",
316
+ render: (args) => ({
317
+ components: { ServicesCard },
318
+ setup() {
319
+ return { args, overlayServices };
320
+ },
321
+ template: `
322
+ <component is="style">
323
+ .services-card.dark-overlay-card {
324
+ --services-card-gap: 0;
325
+ --services-card-border-radius: 0.4rem;
326
+ --services-card-background-color: #1c1817;
327
+ --services-card-background-color-hover: #211c1b;
328
+ --services-card-border-colour: #2e2826;
329
+ --services-card-border-colour-hover: #6b5a4e;
330
+ --services-card-transform-hover: translateY(-0.4rem);
331
+
332
+ --image-wrapper-border-radius: 0;
333
+ --image-wrapper-border-image-zoom-transform: none;
334
+ --image-wrapper-details-scrim-colour: #141110;
335
+ --image-wrapper-details-text-colour: #f3ece4;
336
+ --image-wrapper-details-eyebrow-text-colour: #c9a24a;
337
+ --image-wrapper-details-gap: 0.4rem;
338
+ --eyebrow-text-font-family: "Poppins", sans-serif;
339
+ --eyebrow-text-font-style: normal;
340
+ --eyebrow-text-font-weight: 600;
341
+ --eyebrow-text-letter-spacing: 0.25em;
342
+ --image-wrapper-details-eyebrow-text-padding-inline: 3.6rem;
343
+ --image-wrapper-details-hero-text-padding-block: 0 2.8rem;
344
+ --image-wrapper-details-hero-text-padding-inline: 3.6rem;
345
+
346
+ --details-wrapper-padding-block: 3.6rem;
347
+ --details-wrapper-padding-inline: 3.6rem;
348
+ --description-text-colour: #a39a94;
349
+ --description-line-height: 1.75;
350
+ --description-font-weight: 300;
351
+ --meta-border-colour: #2e2826;
352
+ --meta-padding-block: 2.4rem 0;
353
+ --meta-text-colour: #8a817b;
354
+ --meta-duration-font-weight: 400;
355
+ --meta-price-font-weight: 400;
356
+ --meta-letter-spacing: 0.15em;
357
+ --meta-price-text-colour: #e8dfd6;
358
+ }
359
+ </component>
360
+ <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(min(26rem, 100%), 1fr)); gap:4rem; padding:2.4rem; background:#121010;">
361
+ <ServicesCard
362
+ v-for="service in overlayServices"
363
+ :key="service.slug"
364
+ v-bind="args"
365
+ :service-data="service"
366
+ :href="'#' + service.slug"
367
+ titles-within-image-wrapper
368
+ :style-class-passthrough="['dark-overlay-card']"
369
+ />
370
+ </div>
371
+ `,
372
+ }),
373
+ parameters: {
374
+ docs: {
375
+ description: {
376
+ story:
377
+ "A darker variant built from tokens: titles over the image with a dark scrim, a flush square-cornered image, a padded details panel, and a whole-card link whose hover lightens the border. Every value is a token, set on a class added with styleClassPassthrough; the eyebrow tokens are EyebrowText's own and inherit down into the card.",
378
+ },
379
+ },
380
+ },
381
+ };
@@ -2,7 +2,10 @@
2
2
 
3
3
  exports[`ServicesCard > renders correct HTML structure 1`] = `
4
4
  "<div class="services-card">
5
- <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/test.jpg 1x, /_ipx/_/images/test.jpg 2x" alt="Test Service" loading="lazy" class="image" src="/_ipx/_/images/test.jpg"></div>
5
+ <div class="grid-stack image-wrapper">
6
+ <div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/test.jpg 1x, /_ipx/_/images/test.jpg 2x" alt="Test Service" loading="lazy" class="image" src="/_ipx/_/images/test.jpg"></div>
7
+ <div class="grid-stack__layer"></div>
8
+ </div>
6
9
  <div class="details-wrapper">
7
10
  <div class="eyebrow-text large">A subtitle</div>
8
11
  <h2 class="hero-text heading axis-horizontal">