srcdev-nuxt-components 9.5.1 → 9.5.3

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 (30) hide show
  1. package/.claude/skills/components/canvas-switcher.md +5 -1
  2. package/.claude/skills/components/eyebrow-text.md +6 -4
  3. package/.claude/skills/components/input-error.md +1 -1
  4. package/.claude/skills/components/services-card.md +25 -2
  5. package/.claude/skills/storybook-add-story.md +5 -0
  6. package/.vscode/srcdev-component-eyebrow-text.code-snippets +4 -0
  7. package/.vscode/srcdev-component-services-card.code-snippets +38 -7
  8. package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +47 -13
  9. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +5 -1
  10. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +4 -1
  11. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +28 -3
  12. package/app/components/03.organisms/services/services-card/ServicesCard.vue +111 -18
  13. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +122 -0
  14. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +4 -1
  15. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +100 -19
  16. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +12 -3
  17. package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +2 -2
  18. package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +1 -1
  19. package/app/components/05.forms/input-number/InputNumberField.vue +1 -1
  20. package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +2 -2
  21. package/app/components/05.forms/input-range/InputRangeDefault.vue +1 -1
  22. package/app/components/05.forms/input-select/InputSelectWithLabel.vue +1 -1
  23. package/app/components/05.forms/input-text/InputTextAsNumberWithLabel.vue +1 -1
  24. package/app/components/05.forms/input-text/InputTextWithLabel.vue +1 -1
  25. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +24 -0
  26. package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +1 -1
  27. package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabel.vue +4 -4
  28. package/app/composables/tests/useAriaDescribedById.spec.ts +31 -15
  29. package/app/composables/useAriaDescribedById.ts +6 -2
  30. package/package.json +1 -1
@@ -47,7 +47,11 @@ const canvasName = ref<MediaCanvas>("desktopCanvas");
47
47
  The selected value doubles as a utility class name: `CanvasSwitcher`'s stylesheet also declares
48
48
  `.mobileCanvas`/`.tabletCanvas`/`.laptopCanvas`/`.desktopCanvas`/`.fullWidthCanvas` (max-width
49
49
  utilities, not scoped to `.canvas-switcher`), so binding `:class="canvasName"` directly onto a
50
- preview wrapper constrains it to match — see `DashboardQuadGrid`/`DashboardStatsGrid` stories for the pattern.
50
+ preview wrapper constrains it to match. `DashboardQuadGrid`/`DashboardStatsGrid` stories use the
51
+ classes alone (driven by a Controls select); `PageRow.stories.ts` renders the switcher itself in a
52
+ meta-level decorator, so every story gets an in-canvas width toggle. Copy that decorator for any
53
+ component that sizes itself from its container (`%`, container queries) rather than from
54
+ `@media`: a constrained wrapper cannot trigger a viewport media query.
51
55
  These classes only ship in the bundle once `CanvasSwitcher` (or another importer of them) is
52
56
  actually imported somewhere, since Nuxt only bundles a component's SFC styles when it's used.
53
57
 
@@ -71,9 +71,10 @@ reference: `app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md
71
71
 
72
72
  ## Styling
73
73
 
74
- Key CSS custom properties:
74
+ Key CSS custom properties (full list in `CONSUMER-STYLING.md`):
75
75
 
76
- - `--colour-text-eyebrow` — text colour (defaults to an accent/muted tone)
76
+ - `--eyebrow-text-bg-img` — text fill (gradient via `background-clip: text`; use a single-colour gradient for a solid colour)
77
+ - `--eyebrow-text-font-family`, `--eyebrow-text-font-style` (default `italic`), `--eyebrow-text-font-weight`, `--eyebrow-text-letter-spacing` — typography
77
78
 
78
79
  Text is always `text-transform: uppercase` — do not pass pre-uppercased strings, as this makes content harder to edit and search.
79
80
 
@@ -95,8 +96,9 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
95
96
  ─────────────────────────────────────────────────────────────────── */
96
97
  .eyebrow-text {
97
98
  &.my-eyebrow {
98
- /* Colours */
99
- /* --colour-text-eyebrow: var(--brand-accent); */
99
+ /* --eyebrow-text-bg-img: linear-gradient(var(--brand-accent), var(--brand-accent)); */
100
+ /* --eyebrow-text-font-style: normal; */
101
+ /* --eyebrow-text-letter-spacing: 0.2em; */
100
102
  }
101
103
  }
102
104
  </style>
@@ -41,7 +41,7 @@ No slots.
41
41
  ```vue
42
42
  <template>
43
43
  <div class="my-field">
44
- <input :id :aria-describedby :aria-invalid="fieldHasError" />
44
+ <input :id :aria-describedby="ariaDescribedby()" :aria-invalid="fieldHasError" />
45
45
  <InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="false" :input-variant />
46
46
  </div>
47
47
  </template>
@@ -12,6 +12,7 @@
12
12
  | `tag` | `"div" \| "section" \| "article"` | `"div"` | no |
13
13
  | `href` | `string` | `undefined` | no |
14
14
  | `external` | `boolean` | `false` | no |
15
+ | `titlesWithinImageWrapper` | `boolean` | `false` | no |
15
16
  | `eyebrowConfig` | `ServicesCardEyebrowConfig` | `{}` | no |
16
17
  | `heroConfig` | `ServicesCardHeroConfig` | `{}` | no |
17
18
  | `durationText` | `string` | `undefined` | no |
@@ -30,6 +31,17 @@ If an `actions` slot is provided, the card stays as a static `tag` element (no h
30
31
 
31
32
  `is-clickable` is added to the root class list when whole-card-clickable mode is active, giving `cursor: pointer` and removing default link colour/underline. It's also what gates the border/outline hover/focus interaction-state tokens — see "Interaction states (whole-card-clickable only)" in `CONSUMER-STYLING.md`; they're a no-op when the card isn't clickable, since only `.is-clickable` has a `:hover`/`:focus-visible` rule.
32
33
 
34
+ ### Titles over the image (`titlesWithinImageWrapper`)
35
+
36
+ When `true`, the eyebrow and title move out of `.details-wrapper` and sit over the bottom of the image instead. The image wrapper is a `GridStack`: the image is layer 1, and `.image-wrapper-details` (eyebrow + title) is layer 2. The description, meta row and actions stay below the image.
37
+
38
+ - A gradient scrim behind the text keeps it readable. Its colour comes from `--image-wrapper-details-scrim-colour` (default black), and `--image-wrapper-details-scrim` replaces the whole background (`none` removes it).
39
+ - Text colour uses `contrast-color()` against the scrim colour where supported (black or white, whichever reads better), falling back to white. It reads the scrim colour only, not the photo, so if you remove the scrim, set `--image-wrapper-details-text-colour` yourself.
40
+ - The eyebrow's gradient fill is turned off over the image; it takes the text colour (or `--image-wrapper-details-eyebrow-text-colour`).
41
+ - The eyebrow's default `fontSize` is `"medium"` in this mode (vs `"large"` below the image). `eyebrowConfig`/`heroConfig` still apply.
42
+
43
+ See "Titles over the image" in `CONSUMER-STYLING.md` for the token list.
44
+
33
45
  ### EyebrowConfig
34
46
 
35
47
  Exported as `ServicesCardEyebrowConfig` (and `ServicesCardHeroConfig` below) from `srcdev-nuxt-components` since 2026-09-28.
@@ -37,7 +49,7 @@ Exported as `ServicesCardEyebrowConfig` (and `ServicesCardHeroConfig` below) fro
37
49
  | Key | Type | Default |
38
50
  | ---------- | ---------------------------------- | --------- |
39
51
  | `tag` | `"p" \| "div" \| "span"` | `"div"` |
40
- | `fontSize` | `"large" \| "medium" \| "small"` | `"large"` |
52
+ | `fontSize` | `"large" \| "medium" \| "small"` | `"large"` (`"medium"` with `titlesWithinImageWrapper`) |
41
53
 
42
54
  ### HeroConfig
43
55
 
@@ -159,6 +171,16 @@ See `CONSUMER-STYLING.md` for the full token list — every token is a plain pub
159
171
  consumed directly at its point of use (no `--_`-prefixed private indirection layer), so all of
160
172
  them work both globally (`:root`) and scoped like the example above.
161
173
 
174
+ ## Titles over the image usage
175
+
176
+ ```vue
177
+ <ServicesCard :service-data="service" titles-within-image-wrapper>
178
+ <template #actions="{ serviceData }">
179
+ <InputButton variant="secondary" button-text="Enquire" :href="`/services/${serviceData.slug}`" />
180
+ </template>
181
+ </ServicesCard>
182
+ ```
183
+
162
184
  ## Whole-card clickable usage
163
185
 
164
186
  ```vue
@@ -173,6 +195,7 @@ No `actions` slot — the entire card renders as an anchor (`NuxtLink` for the i
173
195
  - The `Service` type is imported from `~/types/types.services`.
174
196
  - Root markup is two rows (`grid-template-rows: auto 1fr`): `.image-wrapper`, then a `.details-wrapper` (`display: flex; flex-direction: column`) holding the eyebrow, title, description, and a `.footer` wrapper. Description length is controlled by `--description-line-clamp` (default effectively unclamped) rather than a fixed-height grid row.
175
197
  - `.footer` groups the meta row and the `actions` slot and gets `margin-block-start: auto`, pinning them to the bottom of the card. Combined with the root's `1fr` details row, this means when `ServicesCardGrid`'s default `align-items: stretch` makes a card taller than its own content (to match a taller sibling in the same row), the extra height goes to `.footer`'s top margin rather than leaving whitespace below the actions slot — so the meta row and actions/button line up across a row of cards regardless of each card's description length. This does the visual job of CSS subgrid without needing a subgrid chain across `ServicesCard`/`ServicesCardGrid` (which would also break whenever cards in a row don't all render the same optional rows — `.meta` and `actions` are both conditional).
176
- - Image has a `3/4` aspect ratio with a subtle scale-on-hover effect.
198
+ - Image has a `3/4` aspect ratio with a subtle scale-on-hover effect, triggered by hovering the image only (not the whole card). When the card is a whole-card link styled with its own hover effect (e.g. `--services-card-transform-hover`), set `--image-wrapper-border-image-zoom-transform: none` so the two effects don't stack; the "Dark Overlay Cards" story does this. Keep the zoom for the `actions`-slot layout, where the card itself has no hover state. The hover is on `.image-wrapper`, not the image, so it still fires when the overlaid titles cover the image. `.image-wrapper-details` is `position: relative; z-index: 1` because the image's hover `transform` would otherwise paint over it, and `.image-wrapper` sets `isolation: isolate` so that z-index stays inside the card.
177
199
  - `.services-card`, `.image-wrapper`, `.details-wrapper`, and `.footer` all set `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot button label, since `InputButton`'s `.button-text` is `white-space: nowrap` with no ellipsis — forces its content's min-content width up through the flex/grid chain and widens that one card's grid column in `ServicesCardGrid` wider than its siblings (the image just rides along on the widened column; it isn't the actual cause). If you see one card/column wider than the rest with cut-off content, check for a long unbreakable string in a slot before assuming it's an image sizing issue.
178
200
  - Usually consumed via `ServicesCardGrid` rather than directly.
201
+ - The "Dark Overlay Cards" story shows a full restyle using tokens only: dark panel, flush image, titles over the image, hover lift (`--services-card-transform-hover`), and EyebrowText typography tokens (`--eyebrow-text-font-style`, `-letter-spacing`, etc.) set on the card so they inherit into its eyebrow.
@@ -277,6 +277,11 @@ Key points:
277
277
  (`html[data-color-scheme="light"]`, off-white `--slate-00` stage) by default, whatever the OS
278
278
  setting. Use the toolbar "Colour scheme" menu (Light / Dark / Follow OS) to check dark mode.
279
279
  Don't hardcode a stage background in a story to get a light canvas; it's already the default.
280
+ - **Edge-aware components need `layout: "fullscreen"`** in the meta's `parameters`. Storybook's
281
+ default `"padded"` layout insets the canvas by 1rem, which masks viewport gutters, full-bleed
282
+ tracks and edge alignment (`PageRow`, `SliderGallery`). To swap canvas widths inside a story,
283
+ add the `CanvasSwitcher` decorator from `PageRow.stories.ts` (container-sized components only;
284
+ see `components/canvas-switcher.md`).
280
285
  - Use `table: { category: "..." }` in `argTypes` when a component has many props — it groups
281
286
  them in the Storybook controls panel (e.g. `"Model"`, `"Basic"`, `"Validation"`, `"Styling"`, `"Slots"`).
282
287
  - Export multiple named stories (`Default`, `WithError`, `Outlined`, etc.) when you want
@@ -13,6 +13,10 @@
13
13
  ".$1my-page {",
14
14
  " --eyebrow-text-bg-img: linear-gradient(135deg, $2, $3);",
15
15
  " --eyebrow-text-medium: $4;",
16
+ " --eyebrow-text-font-family: ;",
17
+ " --eyebrow-text-font-style: ;",
18
+ " --eyebrow-text-font-weight: ;",
19
+ " --eyebrow-text-letter-spacing: ;",
16
20
  "}"
17
21
  ]
18
22
  }
@@ -25,6 +25,17 @@
25
25
  "<ServicesCard :service-data=\"$1service\" :href=\"`/services/${$1service.slug}`\" />"
26
26
  ]
27
27
  },
28
+ "SRCDEV ServicesCard Titles Over Image": {
29
+ "description": "ServicesCard with eyebrow and title overlaid on the image, over a scrim",
30
+ "scope": "vue,html",
31
+ "body": [
32
+ "<ServicesCard :service-data=\"$1service\" titles-within-image-wrapper>",
33
+ " <template #actions=\"{ serviceData }\">",
34
+ " <InputButton variant=\"secondary\" button-text=\"$2Enquire\" :href=\"`/services/${serviceData.slug}`\" />",
35
+ " </template>",
36
+ "</ServicesCard>"
37
+ ]
38
+ },
28
39
  "SRCDEV ServicesCard With Config Overrides": {
29
40
  "description": "ServicesCard with eyebrowConfig/heroConfig overrides",
30
41
  "scope": "vue,html",
@@ -76,8 +87,13 @@
76
87
  " --services-card-outline-colour-hover: ;",
77
88
  " --services-card-outline-offset: ;",
78
89
  " --services-card-outline-offset-hover: ;",
90
+ " --services-card-background-color-hover: ;",
91
+ " --services-card-transform-hover: ;",
79
92
  "",
80
93
  " --services-card-gap: ;",
94
+ " --services-card-background-color: ;",
95
+ " --services-card-padding-block: ;",
96
+ " --services-card-padding-inline: ;",
81
97
  " --image-wrapper-aspect-ratio: ;",
82
98
  " --image-wrapper-border-radius: ;",
83
99
  " --image-wrapper-padding-block: ;",
@@ -86,21 +102,36 @@
86
102
  " --details-wrapper-grid-gap: ;",
87
103
  " --details-wrapper-padding-block: ;",
88
104
  " --details-wrapper-padding-inline: ;",
105
+ " --eyebrow-text-padding-block: ;",
106
+ " --hero-text-padding-block: ;",
107
+ " --description-text-colour: ;",
89
108
  " --description-padding-block: ;",
90
109
  " --description-line-height: ;",
110
+ " --description-font-weight: ;",
91
111
  " --description-line-clamp: ;",
92
112
  " --meta-padding-block: ;",
113
+ " --meta-border-colour: ;",
114
+ " --meta-text-colour: ;",
115
+ " --meta-duration-text-colour: ;",
116
+ " --meta-price-text-colour: ;",
117
+ " --meta-duration-font-weight: ;",
118
+ " --meta-price-font-weight: ;",
119
+ " --meta-letter-spacing: ;",
120
+ " --meta-font-size: ;",
93
121
  " --meta-text-transform: ;",
94
122
  " --footer-padding-block: ;",
95
123
  " --footer-wrapper-grid-gap: ;",
96
124
  "",
97
- " /* Local-only tokens — page-scoped override only, no :root fallback */",
98
- " --_eyebrow-text-padding-block: ;",
99
- " --_hero-text-padding-block: ;",
100
- " --_description-text-colour: ;",
101
- " --_meta-border-colour: ;",
102
- " --_meta-text-colour: ;",
103
- " --_meta-font-size: ;",
125
+ " /* Titles over the image (titlesWithinImageWrapper) */",
126
+ " --image-wrapper-details-scrim-colour: ;",
127
+ " --image-wrapper-details-scrim: ;",
128
+ " --image-wrapper-details-text-colour: ;",
129
+ " --image-wrapper-details-eyebrow-text-colour: ;",
130
+ " --image-wrapper-details-gap: ;",
131
+ " --image-wrapper-details-eyebrow-text-padding-block: ;",
132
+ " --image-wrapper-details-eyebrow-text-padding-inline: ;",
133
+ " --image-wrapper-details-hero-text-padding-block: ;",
134
+ " --image-wrapper-details-hero-text-padding-inline: ;",
104
135
  " }",
105
136
  "}"
106
137
  ]
@@ -1,5 +1,8 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import { ref } from "vue";
2
3
  import PageRowComponent from "../PageRow.vue";
4
+ import CanvasSwitcher from "../../canvas-switcher/CanvasSwitcher.vue";
5
+ import type { MediaCanvas } from "~/types/components";
3
6
 
4
7
  interface PageRowArgs {
5
8
  tag: string;
@@ -47,7 +50,27 @@ export default {
47
50
  id: "",
48
51
  styleClassPassthrough: [],
49
52
  },
53
+ decorators: [
54
+ // PageRow sizes its tracks from 100% of its container, so a constrained wrapper stands in for a narrower viewport.
55
+ (story) => ({
56
+ components: { story, CanvasSwitcher },
57
+ setup() {
58
+ const canvasName = ref<MediaCanvas>("fullWidthCanvas");
59
+ return { canvasName };
60
+ },
61
+ template: `
62
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
63
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
64
+ </div>
65
+ <div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
66
+ <story />
67
+ </div>
68
+ `,
69
+ }),
70
+ ],
50
71
  parameters: {
72
+ // Storybook's default padded layout hides the viewport gutter and align edge-bleed.
73
+ layout: "fullscreen",
51
74
  docs: {
52
75
  description: {
53
76
  component:
@@ -118,26 +141,37 @@ InsetContent.parameters = {
118
141
  const NestingTemplate: StoryFn = () => ({
119
142
  components: { PageRowComponent },
120
143
  setup() {
121
- return { sampleText };
144
+ const nested = [
145
+ { variant: "full", background: "#fca5a5", desc: "edge to edge" },
146
+ { variant: "popout", background: "#fdba74", desc: "max 1400px" },
147
+ { variant: "content", background: "#fde047", desc: "max 1064px" },
148
+ { variant: "inset-content", background: "#86efac", desc: "max 840px" },
149
+ ];
150
+ const chip =
151
+ "display: inline-block; margin-block-end: 8px; padding: 2px 8px; border-radius: 4px; background: rgba(0,0,0,0.75); color: white; font-family: monospace; font-size: 1.2rem;";
152
+ return { sampleText, nested, chip };
122
153
  },
123
154
  template: `
124
155
  <div style="min-height: 100vh;">
125
156
 
126
- <PageRowComponent tag="section" variant="full" style="background: #1e3a5f; color: white; padding-block: 60px;">
157
+ <PageRowComponent tag="section" style="background: #1e3a5f; color: white; padding-block: 60px;">
158
+ <span :style="chip">outer PageRow (variant="content")</span>
127
159
  <h1 style="margin: 0 0 16px; font-size: 2.4rem;">Full-width background, constrained content</h1>
128
160
  <p style="margin: 0; opacity: 0.85; line-height: 1.7;">{{ sampleText }}</p>
129
161
  </PageRowComponent>
130
162
 
131
- <PageRowComponent tag="section" style="background: #f0fdf4; padding-block: 48px;">
132
- <PageRowComponent variant="content">
133
- <h2 style="margin: 0 0 12px; font-size: 1.8rem; color: #166534;">Nested: content inside a content row</h2>
134
- <p style="margin: 0; color: #15803d; line-height: 1.7;">{{ sampleText }}</p>
135
- </PageRowComponent>
136
- <PageRowComponent variant="popout" style="margin-block: 24px; background: #dcfce7; border-radius: 6px; padding: 24px;">
137
- <p style="margin: 0; color: #166534;">Popout child breaking out to 1400px</p>
138
- </PageRowComponent>
139
- <PageRowComponent variant="inset-content">
140
- <p style="margin: 0; color: #15803d; font-style: italic; line-height: 1.7;">Inset content (840px) for a focused quote or aside.</p>
163
+ <PageRowComponent tag="section" style="background: #0f172a; padding-block: 48px; display: grid; row-gap: 16px;">
164
+ <span :style="chip" style="background: white; color: #0f172a;">outer PageRow (dark background)</span>
165
+ <PageRowComponent
166
+ v-for="row in nested"
167
+ :key="row.variant"
168
+ :variant="row.variant"
169
+ :style="{ background: row.background, color: '#111827', paddingBlock: '24px' }"
170
+ >
171
+ <div>
172
+ <span :style="chip">nested variant="{{ row.variant }}"</span>
173
+ <p style="margin: 0; line-height: 1.6;">{{ row.desc }}: the background shows the track this nested PageRow occupies.</p>
174
+ </div>
141
175
  </PageRowComponent>
142
176
  </PageRowComponent>
143
177
 
@@ -152,7 +186,7 @@ NestingPattern.parameters = {
152
186
  docs: {
153
187
  description: {
154
188
  story:
155
- "The key composition pattern: nest a full-width outer PageRow (for backgrounds/borders) with inner PageRows at narrower variants. No extra wrapper divs needed.",
189
+ "The key composition pattern: an outer PageRow carries the full-width background, and nested PageRows pick a track via variant. Each nested row's background marks the track it occupies against the dark outer row. No extra wrapper divs needed.",
156
190
  },
157
191
  },
158
192
  };
@@ -8,8 +8,12 @@
8
8
  | `--eyebrow-text-large` | `1.4rem` | Font size for `font-size="large"` |
9
9
  | `--eyebrow-text-medium` | `1.2rem` | Font size for `font-size="medium"` (default) |
10
10
  | `--eyebrow-text-small` | `1rem` | Font size for `font-size="small"` |
11
+ | `--eyebrow-text-font-family` | `inherit` | Font family |
12
+ | `--eyebrow-text-font-style` | `italic` | Font style, e.g. `normal` for upright text |
13
+ | `--eyebrow-text-font-weight` | `inherit` | Font weight |
14
+ | `--eyebrow-text-letter-spacing` | `inherit` | Letter-spacing, e.g. `0.25em` for wide tracking |
11
15
 
12
- The library theme also sets all four on `:root` (`03.theming/_default.css` and
16
+ The library theme also sets the colour and size tokens on `:root` (`03.theming/_default.css` and
13
17
  `05.typography/01.tokens/_reponsive-font-sizes.css`) with the same values.
14
18
 
15
19
  > Changed 2026-09-27: the component now carries those values as fallbacks too. Before, an app that
@@ -31,7 +31,10 @@ watch(
31
31
  text-transform: uppercase;
32
32
  background-clip: text;
33
33
  background-image: var(--eyebrow-text-bg-img, linear-gradient(135deg, #c2a770, #b4747e, #d1bd94));
34
- font-style: italic;
34
+ font-family: var(--eyebrow-text-font-family, inherit);
35
+ font-style: var(--eyebrow-text-font-style, italic);
36
+ font-weight: var(--eyebrow-text-font-weight, inherit);
37
+ letter-spacing: var(--eyebrow-text-letter-spacing, inherit);
35
38
  color: transparent;
36
39
 
37
40
  &.large {
@@ -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
  }