srcdev-nuxt-components 9.5.7 → 9.6.0

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 (73) hide show
  1. package/.claude/skills/components/breadcrumb.md +5 -0
  2. package/.claude/skills/components/display-pill.md +2 -0
  3. package/.claude/skills/components/input-button.md +3 -0
  4. package/.claude/skills/components/responsive-header.md +49 -0
  5. package/.claude/skills/components/service-detail.md +32 -0
  6. package/.claude/skills/components/service-summary.md +8 -0
  7. package/.claude/skills/components/services-card-grid.md +5 -3
  8. package/.claude/skills/components/services-card.md +22 -12
  9. package/.claude/skills/components/slider-gallery.md +10 -4
  10. package/.claude/skills/components/tab-navigation.md +25 -9
  11. package/.claude/skills/index.md +1 -2
  12. package/.vscode/srcdev-component-responsive-header.code-snippets +10 -0
  13. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  14. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  15. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  16. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  17. package/.vscode/srcdev-component-tab-navigation.code-snippets +1 -1
  18. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +3 -0
  19. package/app/components/01.atoms/display-pill/DisplayPill.vue +5 -2
  20. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  21. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  22. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  23. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  24. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +52 -11
  25. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +55 -23
  26. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +176 -257
  27. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +42 -2
  28. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  29. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  30. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  31. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  32. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  33. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +4 -0
  34. package/app/components/03.organisms/responsive-header/NavigationItems.vue +33 -4
  35. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +51 -4
  36. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
  37. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
  38. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
  39. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  40. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  41. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  42. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  43. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -3
  44. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  45. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  46. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  47. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  48. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  49. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  50. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  51. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  52. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  53. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  54. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  55. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  56. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  57. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  58. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  59. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  60. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  61. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  62. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  63. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  64. package/app/composables/useAnchorScroll.ts +1 -1
  65. package/nuxt.config.ts +1 -1
  66. package/package.json +1 -1
  67. package/.claude/skills/components/site-navigation.md +0 -145
  68. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  69. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  70. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  71. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  72. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  73. package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
@@ -3,7 +3,7 @@
3
3
  exports[`ServiceSummary > renders correct HTML structure 1`] = `
4
4
  "<div class="service-summary">
5
5
  <div class="service-summary__grid">
6
- <div class="service-summary__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/test.jpg 1x, /_ipx/_/images/test.jpg 2x" alt="Test Service" loading="eager" fetchpriority="high" class="service-summary__image" src="/_ipx/_/images/test.jpg"></div>
6
+ <div class="service-summary__image-wrapper"><img width="1080" height="1080" data-nuxt-img="" srcset="/_ipx/s_1080x1080/images/test.jpg 1x, /_ipx/s_2160x2160/images/test.jpg 2x" alt="Test Service" loading="eager" fetchpriority="high" class="service-summary__image" src="/_ipx/s_1080x1080/images/test.jpg"></div>
7
7
  <div class="service-summary__info-wrapper service-summary__info-wrapper--align-center">
8
8
  <div class="eyebrow-text large">A subtitle</div>
9
9
  <h2 id="v-0-0" class="hero-text mb-20 title axis-horizontal">
@@ -4,7 +4,7 @@
4
4
 
5
5
  Every token below is consumed directly at its point of use (`var(--services-card-gap, 1rem)`,
6
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
7
+ `--services-card-image-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
 
@@ -16,15 +16,16 @@ i.e. `is-clickable` is on the root — since these all default to `transparent`/
16
16
 
17
17
  | Token | Default | Controls |
18
18
  |---|---|---|
19
- | `--services-card-border-radius` | `0` | Root corner radius — also clips `.image-wrapper` (root has `overflow: hidden`), so set `--image-wrapper-border-radius` to match if you want the image's own corners rounded consistently with a rounded root |
19
+ | `--services-card-border-radius` | `0` | Root corner radius — also clips `.services-card-image-wrapper` (root has `overflow: hidden`), so set `--services-card-image-border-radius` to match if you want the image's own corners rounded consistently with a rounded root |
20
20
  | `--services-card-border-width` | `1px` | Border thickness |
21
21
  | `--services-card-border-colour` | `transparent` | Resting (non-hover) border colour |
22
22
  | `--services-card-border-colour-hover` | resting border colour | Border colour on `:hover`/`:focus-visible` — defaults to whatever `--services-card-border-colour` resolves to, so setting only the resting colour keeps the border visible on hover instead of it vanishing |
23
23
  | `--services-card-outline-width` | `2px` | Outline thickness |
24
24
  | `--services-card-outline-colour` | `transparent` | Resting (non-hover) outline colour |
25
- | `--services-card-outline-colour-hover` | `transparent` | Outline colour on `:hover`/`:focus-visible` |
25
+ | `--services-card-outline-colour-hover` | resting outline colour | Outline colour on `:hover`/`:focus-visible` |
26
+ | `--services-card-outline-colour-focus` | hover outline colour, else `var(--theme-ring, currentColor)` | Keyboard focus ring colour. Never transparent by default, so the whole-card link always shows focus |
26
27
  | `--services-card-outline-offset` | `0px` | Resting outline offset |
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-outline-offset-hover` | resting outline offset | 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
29
  | `--services-card-background-color-hover` | resting background colour | Background on `:hover`/`:focus-visible` |
29
30
  | `--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` |
30
31
 
@@ -43,61 +44,97 @@ i.e. `is-clickable` is on the root — since these all default to `transparent`/
43
44
  |---|---|---|
44
45
  | `--services-card-gap` | `1rem` | Gap between the image row and the details row |
45
46
  | `--services-card-background-color` | `transparent` | Card background |
46
- | `--services-card-padding-block` | none | Padding on the card root (block axis) |
47
+ | `--services-card-padding-block` | `0` | Padding on the card root (block axis) |
47
48
  | `--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 |
48
- | `--image-wrapper-aspect-ratio` | `3/4` | Image aspect ratio |
49
- | `--image-wrapper-border-radius` | `8px` | Image corner radius |
50
- | `--image-wrapper-padding-block` | `0 0` | Padding inside the image wrapper (block axis) |
51
- | `--image-wrapper-padding-inline` | `0 0` | Padding inside the image wrapper (inline axis) |
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 |
53
- | `--details-wrapper-grid-gap` | `1rem` | Gap between eyebrow/title/description/footer |
54
- | `--details-wrapper-padding-block` | `0` | Padding inside the details wrapper (block axis) |
55
- | `--details-wrapper-padding-inline` | `0` | Padding inside the details wrapper (inline axis) |
56
- | `--eyebrow-text-padding-block` | `0.8rem 0` | Padding above/below the eyebrow |
57
- | `--hero-text-padding-block` | `2rem 1rem` | Padding above/below the title |
58
- | `--description-padding-block` | `0 0` | Padding on the description text |
59
- | `--description-text-colour` | `var(--colour-text-secondary)` | Description paragraph colour |
60
- | `--description-line-height` | `1.4` | Description line height |
61
- | `--description-font-weight` | `inherit` | Description font weight |
62
- | `--description-line-clamp` | `100` | Number of lines the description clamps to — default is effectively unclamped; set e.g. `3` to clamp |
63
- | `--meta-padding-block` | `1.6rem 0` | Padding above/below the meta row's divider line |
64
- | `--meta-border-colour` | `var(--theme-border)` | Meta row's top divider colour |
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 |
71
- | `--meta-font-size` | `1.4rem` | Duration/price text size |
72
- | `--meta-text-transform` | `uppercase` | Text transform on the duration/price meta row |
73
- | `--footer-padding-block` | `0` | Padding on the `.footer` wrapper (meta row + actions slot) |
74
- | `--footer-wrapper-grid-gap` | `1rem` | Gap between the meta row and the actions slot inside `.footer` |
49
+ | `--services-card-image-aspect-ratio` | `3/4` | Image aspect ratio |
50
+ | `--services-card-image-border-radius` | `8px` | Image corner radius |
51
+ | `--services-card-image-padding-block` | `0 0` | Padding inside the image wrapper (block axis) |
52
+ | `--services-card-image-padding-inline` | `0 0` | Padding inside the image wrapper (inline axis) |
53
+ | `--services-card-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 |
54
+ | `--services-card-details-gap` | `1rem` | Gap between eyebrow/title/description/footer |
55
+ | `--services-card-details-padding-block` | `0` | Padding inside the details wrapper (block axis) |
56
+ | `--services-card-details-padding-inline` | `0` | Padding inside the details wrapper (inline axis) |
57
+ | `--services-card-eyebrow-padding-block` | `0.8rem 0` | Padding above/below the eyebrow |
58
+ | `--services-card-title-padding-block` | `2rem 1rem` | Padding above/below the title |
59
+ | `--services-card-description-padding-block` | `0 0` | Padding on the description text |
60
+ | `--services-card-description-text-colour` | `inherit` | Description paragraph colour |
61
+ | `--services-card-description-line-height` | `1.4` | Description line height |
62
+ | `--services-card-description-font-weight` | `inherit` | Description font weight |
63
+ | `--services-card-description-line-clamp` | `none` | Max lines of the description, with an ellipsis on the last. `1` is single-line ellipsis, `none` shows everything |
64
+ | `--services-card-meta-padding-block` | `1.6rem 0` | Padding above/below the meta row's divider line |
65
+ | `--services-card-meta-border-colour` | `var(--theme-border)` | Meta row's top divider colour |
66
+ | `--services-card-meta-text-colour` | `inherit` | Duration/price text colour |
67
+ | `--services-card-meta-duration-text-colour` | meta text colour | Duration colour only |
68
+ | `--services-card-meta-price-text-colour` | meta text colour | Price colour only, e.g. brighter than the duration |
69
+ | `--services-card-meta-duration-font-weight` | `500` | Duration font weight |
70
+ | `--services-card-meta-price-font-weight` | `700` | Price font weight |
71
+ | `--services-card-meta-letter-spacing` | `inherit` | Letter-spacing on the meta row |
72
+ | `--services-card-meta-font-size` | `1.4rem` | Duration/price text size |
73
+ | `--services-card-meta-text-transform` | `uppercase` | Text transform on the duration/price meta row |
74
+ | `--services-card-meta-gap` | `1rem` | Gap between the duration and price (and between rows when long values wrap) |
75
+ | `--services-card-footer-padding-block` | `0` | Padding on the `.services-card-footer` wrapper (meta row + actions slot) |
76
+ | `--services-card-footer-gap` | `1rem` | Gap between the meta row and the actions slot inside `.services-card-footer` |
75
77
 
76
78
  ### Titles over the image (`titlesWithinImageWrapper`)
77
79
 
78
80
  | Token | Default | Controls |
79
81
  |---|---|---|
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 |
82
+ | `--services-card-image-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 |
83
+ | `--services-card-image-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 |
84
+ | `--services-card-image-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 |
85
+ | `--services-card-image-details-eyebrow-colour` | `inherit` | Eyebrow colour. The eyebrow's gradient fill is turned off over the image so it can't clash with the photo |
86
+ | `--services-card-image-details-gap` | `0.5rem` | Gap between eyebrow and title |
87
+ | `--services-card-image-details-eyebrow-padding-block` / `-inline` | `0` / `2.2rem` | Eyebrow padding |
88
+ | `--services-card-image-details-title-padding-block` / `-inline` | `1.2rem 2.2rem` / `2.2rem` | Title padding |
87
89
 
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.
90
+ `contrast-color()` only reads the scrim colour, not the photo. If you remove the scrim, set `--services-card-image-details-text-colour` yourself to suit the images.
89
91
 
90
92
  ```css
91
93
  /* assets/styles/setup/07.components/services-card.css */
92
94
  :root {
93
95
  --services-card-gap: 1.4rem;
94
- --description-line-clamp: 3;
95
- --meta-text-transform: none;
96
+ --services-card-description-line-clamp: 3;
97
+ --services-card-meta-text-transform: none;
96
98
  }
97
99
  ```
98
100
 
99
101
  ---
100
102
 
103
+ ## State hooks
104
+
105
+ | Hook | When |
106
+ |---|---|
107
+ | `.services-card.is-clickable` | Whole-card link mode: `href` set and no `actions` slot (root renders as `<a>` or `NuxtLink`) |
108
+
109
+ Inner classes: `.services-card-image-wrapper`, `.services-card-image`, `.services-card-image-details`
110
+ (titles over the image), `.services-card-details`, `.services-card-description`, `.services-card-footer`,
111
+ `.services-card-meta`, `.services-card-meta-duration`, `.services-card-meta-price`. The eyebrow and
112
+ title are `EyebrowText` (`.eyebrow-text`) and `HeroText` (`.hero-text`), so their own tokens
113
+ (`--eyebrow-text-*`, `--hero-text-*`) inherit down into the card.
114
+
115
+ Empty `subtitle`, `title` or `shortDescription` render no element at all, and the image overlay is
116
+ left out when both titles are empty.
117
+
118
+ > Changed 2026-10-06: every inner class and card-specific token is now prefixed `services-card-`.
119
+ > Old to new: `.image-wrapper` → `.services-card-image-wrapper`, `.image` → `.services-card-image`,
120
+ > `.image-wrapper-details` → `.services-card-image-details`, `.details-wrapper` → `.services-card-details`,
121
+ > `.description` / `.footer` / `.meta` / `.meta-duration` / `.meta-price` → `.services-card-*`;
122
+ > `--image-wrapper-details-*` → `--services-card-image-details-*` (with `-eyebrow-text-colour` → `-eyebrow-colour`,
123
+ > `-eyebrow-text-padding-*` → `-eyebrow-padding-*`, `-hero-text-padding-*` → `-title-padding-*`),
124
+ > `--image-wrapper-border-image-zoom-transform` → `--services-card-image-zoom-transform`,
125
+ > `--image-wrapper-*` → `--services-card-image-*`, `--details-wrapper-grid-gap` → `--services-card-details-gap`,
126
+ > `--details-wrapper-padding-*` → `--services-card-details-padding-*`,
127
+ > `--eyebrow-text-padding-block` → `--services-card-eyebrow-padding-block`,
128
+ > `--hero-text-padding-block` → `--services-card-title-padding-block`, `--description-*` / `--meta-*` →
129
+ > `--services-card-description-*` / `--services-card-meta-*`, `--footer-wrapper-grid-gap` →
130
+ > `--services-card-footer-gap`, `--footer-padding-block` → `--services-card-footer-padding-block`.
131
+ > Also: the description line clamp defaults to `none` (was `100`), the description colour defaults
132
+ > to `inherit` (its old fallback, `--colour-text-secondary`, was never defined, so nothing changes
133
+ > visually), the outline hover tokens fall back to the resting outline values, and keyboard focus now
134
+ > shows a ring by default (`--services-card-outline-colour-focus`; it was transparent).
135
+
136
+ ---
137
+
101
138
  ## Text content — props/slots, not CSS
102
139
 
103
140
  Duration and price text come from `serviceData.duration`/`serviceData.price` by default, or
@@ -107,6 +144,26 @@ the tokens above. See the component skill doc for slot/prop usage examples.
107
144
 
108
145
  ---
109
146
 
147
+ ## Footer bottom-alignment (row-of-cards use)
148
+
149
+ `.services-card-footer` (the meta row + `actions` slot, grouped together) gets `margin-block-start: auto`
150
+ and the root uses `grid-template-rows: auto 1fr`, so when `ServicesCardGrid`'s default
151
+ `align-items: stretch` makes a card taller than its own content (to match a taller sibling in
152
+ the same row), the extra height goes to `.services-card-footer`'s top margin instead of leaving whitespace
153
+ below the actions slot. This keeps the meta row and actions/button aligned across a row of
154
+ cards regardless of each card's description length, without needing CSS subgrid.
155
+
156
+ `.services-card`, `.services-card-image-wrapper`, `.services-card-details`, and `.services-card-footer` all set
157
+ `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot
158
+ button label, since `InputButton`'s `.button-text` is `white-space: nowrap` with no
159
+ ellipsis — forces its content's min-content width up through the flex/grid chain and widens
160
+ that one card's grid column in `ServicesCardGrid` wider than its siblings. If a card/column
161
+ ever looks wider than the rest with content cut off at the edge, check for a long unbreakable
162
+ string in a slot before assuming it's an image sizing issue — the image is just riding along
163
+ on the widened column, not the cause.
164
+
165
+ ---
166
+
110
167
  ## Local overrides
111
168
 
112
169
  Set the tokens above on an element you own (a page or section class, or a class added with
@@ -126,38 +183,22 @@ Use sparingly — prefer global or page-scoped CSS. When a single instance needs
126
183
  ```css
127
184
  .services-card.featured-service {
128
185
  --services-card-gap: 1.6rem;
129
- --meta-border-colour: var(--color-accent);
186
+ --services-card-meta-border-colour: var(--color-accent);
130
187
  }
131
188
  ```
132
189
 
133
190
  ---
134
191
 
135
- ## Footer bottom-alignment (row-of-cards use)
192
+ ## Class passthrough
136
193
 
137
- `.footer` (the meta row + `actions` slot, grouped together) gets `margin-block-start: auto`
138
- and the root uses `grid-template-rows: auto 1fr`, so when `ServicesCardGrid`'s default
139
- `align-items: stretch` makes a card taller than its own content (to match a taller sibling in
140
- the same row), the extra height goes to `.footer`'s top margin instead of leaving whitespace
141
- below the actions slot. This keeps the meta row and actions/button aligned across a row of
142
- cards regardless of each card's description length, without needing CSS subgrid.
143
-
144
- `.services-card`, `.image-wrapper`, `.details-wrapper`, and `.footer` all set
145
- `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot
146
- button label, since `InputButton`'s `.button-text` is `white-space: nowrap` with no
147
- ellipsis — forces its content's min-content width up through the flex/grid chain and widens
148
- that one card's grid column in `ServicesCardGrid` wider than its siblings. If a card/column
149
- ever looks wider than the rest with content cut off at the edge, check for a long unbreakable
150
- string in a slot before assuming it's an image sizing issue — the image is just riding along
151
- on the widened column, not the cause.
194
+ `style-class-passthrough` adds classes to the root `.services-card`. Reactive after mount.
152
195
 
153
196
  ---
154
197
 
155
198
  ## Notes
156
199
 
157
- - `--details-wrapper-grid-gap` (details-wrapper's own children) and `--footer-wrapper-grid-gap`
158
- (the meta row ↔ actions slot gap inside `.footer`) are separate tokens — overriding one does
200
+ - `--services-card-details-gap` (details-wrapper's own children) and `--services-card-footer-gap`
201
+ (the meta row ↔ actions slot gap inside `.services-card-footer`) are separate tokens — overriding one does
159
202
  not affect the other.
160
- - `.meta`'s internal gap (between the duration and price text) is a fixed `1rem`, not a token.
161
203
  - Root element tag, `href`/`external` (whole-card-clickable), `eyebrowConfig`, and `heroConfig`
162
204
  are props, not CSS — see the component skill doc.
163
-
@@ -5,18 +5,30 @@
5
5
  class="services-card"
6
6
  :class="[elementClasses, { 'is-clickable': isClickable() }]"
7
7
  >
8
- <GridStack tag="div" :style-class-passthrough="['image-wrapper']">
8
+ <GridStack tag="div" :style-class-passthrough="['services-card-image-wrapper']">
9
9
  <template #layer-1>
10
- <NuxtImg :src="serviceData.image" :alt="serviceData.title" loading="lazy" class="image" />
10
+ <NuxtImg
11
+ :src="serviceData.image"
12
+ :alt="serviceData.title"
13
+ width="640"
14
+ height="853"
15
+ loading="lazy"
16
+ class="services-card-image"
17
+ />
11
18
  </template>
12
19
  <template #layer-2>
13
- <div v-if="props.titlesWithinImageWrapper" class="image-wrapper-details">
20
+ <div
21
+ v-if="props.titlesWithinImageWrapper && (serviceData.subtitle || serviceData.title)"
22
+ class="services-card-image-details"
23
+ >
14
24
  <EyebrowText
25
+ v-if="serviceData.subtitle"
15
26
  :font-size="eyebrowConfig.fontSize ?? 'medium'"
16
27
  :tag="eyebrowConfig.tag ?? 'div'"
17
28
  :text-content="serviceData.subtitle"
18
29
  />
19
30
  <HeroText
31
+ v-if="serviceData.title"
20
32
  :tag="heroConfig.tag ?? 'h2'"
21
33
  :font-size="heroConfig.fontSize ?? 'heading'"
22
34
  :text-content="[
@@ -30,15 +42,15 @@
30
42
  </template>
31
43
  </GridStack>
32
44
 
33
- <div class="details-wrapper">
45
+ <div class="services-card-details">
34
46
  <EyebrowText
35
- v-if="!props.titlesWithinImageWrapper"
47
+ v-if="!props.titlesWithinImageWrapper && serviceData.subtitle"
36
48
  :font-size="eyebrowConfig.fontSize ?? 'large'"
37
49
  :tag="eyebrowConfig.tag ?? 'div'"
38
50
  :text-content="serviceData.subtitle"
39
51
  />
40
52
  <HeroText
41
- v-if="!props.titlesWithinImageWrapper"
53
+ v-if="!props.titlesWithinImageWrapper && serviceData.title"
42
54
  :tag="heroConfig.tag ?? 'h2'"
43
55
  :font-size="heroConfig.fontSize ?? 'heading'"
44
56
  :text-content="[
@@ -48,15 +60,15 @@
48
60
  },
49
61
  ]"
50
62
  />
51
- <div class="description">
63
+ <div v-if="serviceData.shortDescription" class="services-card-description">
52
64
  {{ serviceData.shortDescription }}
53
65
  </div>
54
- <div v-if="hasFooter()" class="footer">
55
- <div v-if="hasMeta()" class="meta">
56
- <div class="meta-duration">
66
+ <div v-if="hasFooter()" class="services-card-footer">
67
+ <div v-if="hasMeta()" class="services-card-meta">
68
+ <div class="services-card-meta-duration">
57
69
  <slot name="duration" :service-data="serviceData">{{ durationText }}</slot>
58
70
  </div>
59
- <div class="meta-price">
71
+ <div class="services-card-meta-price">
60
72
  <slot name="price" :service-data="serviceData">{{ priceText }}</slot>
61
73
  </div>
62
74
  </div>
@@ -141,7 +153,7 @@ watch(
141
153
  min-inline-size: 0;
142
154
 
143
155
  background-color: var(--services-card-background-color, transparent);
144
- padding-block: var(--services-card-padding-block,);
156
+ padding-block: var(--services-card-padding-block, 0);
145
157
  padding-inline: var(--services-card-padding-inline, 0);
146
158
  border-radius: var(--services-card-border-radius, 0);
147
159
  border: var(--services-card-border-width, 1px) solid var(--services-card-border-colour, transparent);
@@ -170,11 +182,18 @@ watch(
170
182
  var(--services-card-background-color, transparent)
171
183
  );
172
184
  border-color: var(--services-card-border-colour-hover, var(--services-card-border-colour, transparent));
173
- outline-color: var(--services-card-outline-colour-hover, transparent);
174
- outline-offset: var(--services-card-outline-offset-hover, 0px);
185
+ outline-color: var(--services-card-outline-colour-hover, var(--services-card-outline-colour, transparent));
186
+ outline-offset: var(--services-card-outline-offset-hover, var(--services-card-outline-offset, 0px));
175
187
  transform: var(--services-card-transform-hover, none);
176
188
  }
177
189
 
190
+ &:focus-visible {
191
+ outline-color: var(
192
+ --services-card-outline-colour-focus,
193
+ var(--services-card-outline-colour-hover, var(--theme-ring, currentColor))
194
+ );
195
+ }
196
+
178
197
  @media (prefers-reduced-motion: reduce) {
179
198
  &:hover,
180
199
  &:focus-visible {
@@ -183,16 +202,16 @@ watch(
183
202
  }
184
203
  }
185
204
 
186
- .image-wrapper {
187
- aspect-ratio: var(--image-wrapper-aspect-ratio, 3/4);
188
- border-radius: var(--image-wrapper-border-radius, 8px);
205
+ .services-card-image-wrapper {
206
+ aspect-ratio: var(--services-card-image-aspect-ratio, 3/4);
207
+ border-radius: var(--services-card-image-border-radius, 8px);
189
208
  overflow: hidden;
190
209
  isolation: isolate;
191
210
  min-inline-size: 0;
192
- padding-block: var(--image-wrapper-padding-block, 0 0);
193
- padding-inline: var(--image-wrapper-padding-inline, 0 0);
211
+ padding-block: var(--services-card-image-padding-block, 0 0);
212
+ padding-inline: var(--services-card-image-padding-inline, 0 0);
194
213
 
195
- .image {
214
+ .services-card-image {
196
215
  display: block;
197
216
  object-fit: cover;
198
217
  width: 100%;
@@ -200,107 +219,114 @@ watch(
200
219
  transition: transform 0.3s ease-in-out;
201
220
  }
202
221
 
203
- &:hover .image {
204
- transform: var(--image-wrapper-border-image-zoom-transform, scale(1.05));
222
+ &:hover .services-card-image {
223
+ transform: var(--services-card-image-zoom-transform, scale(1.05));
205
224
  }
206
225
 
207
- .image-wrapper-details {
208
- --_scrim-colour: var(--image-wrapper-details-scrim-colour, #000);
226
+ .services-card-image-details {
227
+ --_scrim-colour: var(--services-card-image-details-scrim-colour, #000);
209
228
 
210
229
  position: relative;
211
230
  z-index: 1;
212
231
  display: grid;
213
232
  grid-auto-flow: row;
214
233
  align-content: end;
215
- gap: var(--image-wrapper-details-gap, 0.5rem);
234
+ gap: var(--services-card-image-details-gap, 0.5rem);
216
235
  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%));
236
+ overflow-wrap: anywhere;
237
+ color: var(--services-card-image-details-text-colour, #fff);
238
+ background: var(--services-card-image-details-scrim, linear-gradient(to top, rgb(0 0 0 / 0.75), transparent 70%));
219
239
 
220
240
  @supports (color: color-mix(in oklab, red, transparent)) {
221
241
  background: var(
222
- --image-wrapper-details-scrim,
242
+ --services-card-image-details-scrim,
223
243
  linear-gradient(to top, color-mix(in oklab, var(--_scrim-colour) 75%, transparent), transparent 70%)
224
244
  );
225
245
  }
226
246
 
227
247
  @supports (color: contrast-color(red)) {
228
- color: var(--image-wrapper-details-text-colour, contrast-color(var(--_scrim-colour)));
248
+ color: var(--services-card-image-details-text-colour, contrast-color(var(--_scrim-colour)));
229
249
  }
230
250
 
231
251
  .eyebrow-text {
232
252
  --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);
253
+ color: var(--services-card-image-details-eyebrow-colour, inherit);
254
+ padding-block: var(--services-card-image-details-eyebrow-padding-block, 0);
255
+ padding-inline: var(--services-card-image-details-eyebrow-padding-inline, 2.2rem);
236
256
  }
237
257
 
238
258
  .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);
259
+ padding-block: var(--services-card-image-details-title-padding-block, 1.2rem 2.2rem);
260
+ padding-inline: var(--services-card-image-details-title-padding-inline, 2.2rem);
241
261
  }
242
262
  }
243
263
  }
244
264
 
245
- .details-wrapper {
265
+ .services-card-details {
246
266
  display: flex;
247
267
  flex-direction: column;
248
- gap: var(--details-wrapper-grid-gap, 1rem);
268
+ gap: var(--services-card-details-gap, 1rem);
249
269
  min-inline-size: 0;
270
+ overflow-wrap: anywhere;
250
271
 
251
- padding-block: var(--details-wrapper-padding-block, 0);
252
- padding-inline: var(--details-wrapper-padding-inline, 0);
272
+ padding-block: var(--services-card-details-padding-block, 0);
273
+ padding-inline: var(--services-card-details-padding-inline, 0);
253
274
 
254
275
  .eyebrow-text {
255
- padding-block: var(--eyebrow-text-padding-block, 0.8rem 0);
276
+ padding-block: var(--services-card-eyebrow-padding-block, 0.8rem 0);
256
277
  }
257
278
 
258
279
  .hero-text {
259
- padding-block: var(--hero-text-padding-block, 1.2rem 1rem);
280
+ padding-block: var(--services-card-title-padding-block, 1.2rem 1rem);
260
281
  }
261
282
 
262
- .description {
263
- color: var(--description-text-colour, var(--colour-text-secondary));
264
- font-weight: var(--description-font-weight, inherit);
265
- line-height: var(--description-line-height, 1.4);
266
- padding-block: var(--description-padding-block, 0 0);
283
+ .services-card-description {
284
+ color: var(--services-card-description-text-colour, inherit);
285
+ font-weight: var(--services-card-description-font-weight, inherit);
286
+ line-height: var(--services-card-description-line-height, 1.4);
287
+ padding-block: var(--services-card-description-padding-block, 0 0);
267
288
 
268
289
  display: -webkit-box;
269
290
  -webkit-box-orient: vertical;
270
291
  overflow: hidden;
271
- -webkit-line-clamp: var(--description-line-clamp, 100);
272
- line-clamp: var(--description-line-clamp, 100);
273
- text-overflow: ellipsis;
292
+ -webkit-line-clamp: var(--services-card-description-line-clamp, none);
293
+ line-clamp: var(--services-card-description-line-clamp, none);
274
294
  }
275
295
 
276
- .footer {
296
+ .services-card-footer {
277
297
  display: flex;
278
298
  flex-direction: column;
279
- gap: var(--footer-wrapper-grid-gap, 1rem);
299
+ gap: var(--services-card-footer-gap, 1rem);
280
300
  margin-block-start: auto;
281
301
  min-inline-size: 0;
282
- padding-block: var(--footer-padding-block, 0);
302
+ padding-block: var(--services-card-footer-padding-block, 0);
283
303
 
284
- .meta {
304
+ .services-card-meta {
285
305
  display: flex;
306
+ flex-wrap: wrap;
286
307
  align-items: center;
287
308
  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);
309
+ gap: var(--services-card-meta-gap, 1rem);
310
+ padding-block: var(--services-card-meta-padding-block, 1.6rem 0);
311
+ border-block-start: 1px solid var(--services-card-meta-border-colour, var(--theme-border));
312
+ color: var(--services-card-meta-text-colour, inherit);
313
+ font-size: var(--services-card-meta-font-size, 1.4rem);
314
+ letter-spacing: var(--services-card-meta-letter-spacing, inherit);
315
+ text-transform: var(--services-card-meta-text-transform, uppercase);
316
+
317
+ .services-card-meta-duration,
318
+ .services-card-meta-price {
319
+ min-inline-size: 0;
320
+ }
321
+
322
+ .services-card-meta-duration {
323
+ color: var(--services-card-meta-duration-text-colour, inherit);
324
+ font-weight: var(--services-card-meta-duration-font-weight, 500);
299
325
  }
300
326
 
301
- .meta-price {
302
- color: var(--meta-price-text-colour, inherit);
303
- font-weight: var(--meta-price-font-weight, 700);
327
+ .services-card-meta-price {
328
+ color: var(--services-card-meta-price-text-colour, inherit);
329
+ font-weight: var(--services-card-meta-price-font-weight, 700);
304
330
  }
305
331
  }
306
332
  }