srcdev-nuxt-components 9.3.11 → 9.3.13

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 (77) hide show
  1. package/.claude/skills/component-aria-landmark.md +3 -3
  2. package/.claude/skills/components/auto-grid.md +1 -1
  3. package/.claude/skills/components/page-hero-highlights.md +1 -1
  4. package/.claude/skills/components/service-detail.md +13 -14
  5. package/.claude/skills/components/service-summary-grid.md +141 -0
  6. package/.claude/skills/components/service-summary.md +165 -0
  7. package/.claude/skills/icon-sets.md +1 -1
  8. package/.claude/skills/index.md +3 -2
  9. package/.claude/skills/theming-component-token-pattern.md +126 -0
  10. package/.vscode/srcdev-component-service-summary.code-snippets +67 -0
  11. package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +10 -1
  12. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +3 -3
  13. package/app/components/02.molecules/input-copy/InputCopy.vue +9 -3
  14. package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +3 -3
  15. package/app/components/02.molecules/pricing-card/PricingCard.vue +6 -3
  16. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +7 -8
  17. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +0 -1
  18. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +2 -2
  19. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +116 -0
  20. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +183 -0
  21. package/app/components/03.organisms/services/{services-section/stories/ServicesSection.stories.ts → service-summary/stories/ServiceSummary.stories.ts} +78 -148
  22. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +210 -0
  23. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +17 -0
  24. package/app/components/03.organisms/services/services-grids/{ServicesSectionGrid.vue → ServiceSummaryGrid.vue} +8 -10
  25. package/app/components/03.organisms/services/services-grids/stories/{ServicesSectionGrid.stories.ts → ServiceSummaryGrid.stories.ts} +21 -21
  26. package/app/components/03.organisms/services/services-grids/tests/{ServicesSectionGrid.spec.ts → ServiceSummaryGrid.spec.ts} +28 -28
  27. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +45 -0
  28. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +117 -0
  29. package/app/components/05.forms/input-button/InputButtonCore.vue +83 -37
  30. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-secondary-chromium-darwin.png +0 -0
  31. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-tertiary-chromium-darwin.png +0 -0
  32. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-primary-pending-chromium-darwin.png +0 -0
  33. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-secondary-pill-chromium-darwin.png +0 -0
  34. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-tertiary-readonly-chromium-darwin.png +0 -0
  35. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-chromium-darwin.png +0 -0
  36. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-default-chromium-darwin.png +0 -0
  37. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-error-chromium-darwin.png +0 -0
  38. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-success-chromium-darwin.png +0 -0
  39. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-warning-chromium-darwin.png +0 -0
  40. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-primary-chromium-darwin.png +0 -0
  41. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-secondary-chromium-darwin.png +0 -0
  42. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-tertiary-chromium-darwin.png +0 -0
  43. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/nuxt-icon-only-component-chromium-darwin.png +0 -0
  44. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pending-chromium-darwin.png +0 -0
  45. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pill-chromium-darwin.png +0 -0
  46. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-readonly-chromium-darwin.png +0 -0
  47. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/with-both-nuxt-icon-components-chromium-darwin.png +0 -0
  48. package/app/components/05.forms/input-button/stories/InputButtonCore.stories.ts +35 -0
  49. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +68 -0
  50. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +19 -9
  51. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioCore.vue +8 -5
  52. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +93 -0
  53. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioCore.stories.ts +75 -0
  54. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +41 -0
  55. package/app/components/05.forms/input-number/InputNumberCore.vue +12 -6
  56. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +35 -29
  57. package/app/components/05.forms/input-select/InputSelectCore.vue +32 -20
  58. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +69 -0
  59. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +38 -48
  60. package/app/components/05.forms/input-text/InputTextCore.vue +35 -18
  61. package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +184 -0
  62. package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +125 -0
  63. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +49 -0
  64. package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +23 -16
  65. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +72 -0
  66. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +42 -20
  67. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitchCore.stories.ts +63 -0
  68. package/app/components/display-theme-switch/DisplayThemeSwitch.vue +13 -6
  69. package/package.json +1 -1
  70. package/.claude/skills/components/services-section-grid.md +0 -130
  71. package/.claude/skills/components/services-section.md +0 -211
  72. package/.vscode/srcdev-component-services-section.code-snippets +0 -84
  73. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +0 -87
  74. package/app/components/03.organisms/services/services-section/CONSUMER-STYLING.md +0 -149
  75. package/app/components/03.organisms/services/services-section/ServicesSection.vue +0 -345
  76. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +0 -297
  77. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +0 -87
@@ -14,9 +14,9 @@ or instance) without touching the component itself.
14
14
  | `--input-copy-input-text-color` | `#333` | Input text colour |
15
15
  | `--input-copy-input-placeholder-color` | `#999` | Placeholder text colour |
16
16
  | `--input-copy-input-font-size` | `0.95rem` | Input field font size |
17
- | `--input-copy-button-bg` | `var(--theme-button-primary-surface)` | Copy button background |
18
- | `--input-copy-button-bg-hover` | `var(--theme-button-primary-surface-hover)` | Copy button background on hover |
19
- | `--input-copy-button-text-color` | `var(--theme-button-primary-text)` | Copy button text colour |
17
+ | `--input-copy-button-bg` | `var(--input-button-primary-surface)` | Copy button background |
18
+ | `--input-copy-button-bg-hover` | `var(--input-button-primary-surface-hover)` | Copy button background on hover |
19
+ | `--input-copy-button-text-color` | `var(--input-button-primary-text)` | Copy button text colour |
20
20
  | `--input-copy-button-padding` | `0.8rem 1.2rem` | Copy button padding |
21
21
  | `--input-copy-button-border-radius` | `0.4rem` | Copy button corner rounding |
22
22
  | `--input-copy-button-gap` | `0.6rem` | Gap between icon and text in button |
@@ -102,9 +102,15 @@ onUnmounted(() => {
102
102
  --_input-placeholder-color: var(--input-copy-input-placeholder-color, #999);
103
103
  --_input-font-size: var(--input-copy-input-font-size, 0.95rem);
104
104
 
105
- --_button-bg: var(--input-copy-button-bg, var(--theme-button-primary-surface));
106
- --_button-bg-hover: var(--input-copy-button-bg-hover, var(--theme-button-primary-surface-hover));
107
- --_button-text-color: var(--input-copy-button-text-color, var(--theme-button-primary-text));
105
+ --_button-bg: var(--input-copy-button-bg, var(--input-button-primary-surface, var(--theme-surface)));
106
+ --_button-bg-hover: var(
107
+ --input-copy-button-bg-hover,
108
+ var(--input-button-primary-surface-hover, var(--theme-surface-hover))
109
+ );
110
+ --_button-text-color: var(
111
+ --input-copy-button-text-color,
112
+ var(--input-button-primary-text, var(--theme-on-surface))
113
+ );
108
114
  --_button-padding: var(--input-copy-button-padding, 0.8rem 1.2rem);
109
115
  --_button-border-radius: var(--input-copy-button-border-radius, 0.4rem);
110
116
  --_button-gap: var(--input-copy-button-gap, 0.6rem);
@@ -33,9 +33,9 @@ override it — there is no locale-aware formatting, just a literal prefix chara
33
33
  | `--pricing-card-description-color` | `#555` | Plan description text colour |
34
34
  | `--pricing-card-feature-color` | `#333` | Feature list text colour |
35
35
  | `--pricing-card-cta-align` | `flex-start` | CTA wrapper `align-self` — set to `stretch`, `center`, or `flex-end` to change CTA alignment/width within the card |
36
- | `--pricing-card-cta-bg` | `var(--theme-button-primary-surface)` | CTA button background |
37
- | `--pricing-card-cta-bg-hover` | `var(--theme-button-primary-surface-hover)` | CTA button background on hover |
38
- | `--pricing-card-cta-text` | `var(--theme-button-primary-text)` | CTA button text colour |
36
+ | `--pricing-card-cta-bg` | `var(--input-button-primary-surface)` | CTA button background |
37
+ | `--pricing-card-cta-bg-hover` | `var(--input-button-primary-surface-hover)` | CTA button background on hover |
38
+ | `--pricing-card-cta-text` | `var(--input-button-primary-text)` | CTA button text colour |
39
39
  | `--pricing-card-cta-padding` | `1rem 1.6rem` | CTA button padding |
40
40
  | `--pricing-card-cta-border-radius` | `0.4rem` | CTA button corner rounding |
41
41
 
@@ -111,9 +111,12 @@ watch(
111
111
  --_feature-color: var(--pricing-card-feature-color, #333);
112
112
 
113
113
  --_cta-align: var(--pricing-card-cta-align, flex-start);
114
- --_cta-bg: var(--pricing-card-cta-bg, var(--theme-button-primary-surface));
115
- --_cta-bg-hover: var(--pricing-card-cta-bg-hover, var(--theme-button-primary-surface-hover));
116
- --_cta-text: var(--pricing-card-cta-text, var(--theme-button-primary-text));
114
+ --_cta-bg: var(--pricing-card-cta-bg, var(--input-button-primary-surface, var(--theme-surface)));
115
+ --_cta-bg-hover: var(
116
+ --pricing-card-cta-bg-hover,
117
+ var(--input-button-primary-surface-hover, var(--theme-surface-hover))
118
+ );
119
+ --_cta-text: var(--pricing-card-cta-text, var(--input-button-primary-text, var(--theme-on-surface)));
117
120
  --_cta-padding: var(--pricing-card-cta-padding, 1rem 1.6rem);
118
121
  --_cta-border-radius: var(--pricing-card-cta-border-radius, 0.4rem);
119
122
 
@@ -26,7 +26,7 @@ The closing full-width CTA banner is wrapped the same way via `finalCtaVariant`
26
26
  outer `PageRow` purely to constrain either section's width — pass `body-variant`/`final-cta-variant`
27
27
  directly. It's still fine (and necessary for a genuine full-bleed hero) to place `ServiceDetail`
28
28
  inside an outer, wider `PageRow` — see
29
- [services-section.md](../../../../../.claude/skills/components/services-section.md)-style nesting
29
+ [service-summary.md](../../../../../.claude/skills/components/service-summary.md)-style nesting
30
30
  notes in [page-row.md](../../../../../.claude/skills/components/page-row.md) for how nested page-rows
31
31
  compose.
32
32
 
@@ -49,11 +49,10 @@ compose.
49
49
  | `--service-detail-hero-pill-bg` | `transparent` | Background of the price/duration pills (via `DisplayPill`'s `--theme-pill-bg`) |
50
50
  | `--service-detail-hero-pill-border-colour` | `currentColor` | Border colour of the price/duration pills |
51
51
 
52
- The hero image's crop behaviour also changes responsively (position only, not tokened as fit
53
- itself): below the `768px` container breakpoint it's `object-fit: cover` anchored with
52
+ The hero image is always `object-fit: cover` (not tokened) — only its anchor point changes
53
+ responsively: below the `768px` container breakpoint it's anchored with
54
54
  `--service-detail-hero-image-position-small` (default `bottom`); from `768px` up it switches to
55
- `object-fit: contain` anchored with `--service-detail-hero-image-position-medium` (default
56
- `center`) so the full photo stays visible once there's room for it, rather than continuing to crop.
55
+ `--service-detail-hero-image-position-medium` (default `center`).
57
56
 
58
57
  For anything not covered by these (e.g. the `full`/`content` variant tracks themselves), target
59
58
  `--page-row-*` tokens under `.service-detail__hero-image-row`/`.service-detail__hero-overlay` — see
@@ -211,7 +210,7 @@ Section headings, sidebar labels, and CTA copy are **props**, not hardcoded stri
211
210
 
212
211
  - Routing is entirely delegated to the consumer via slots: `book-cta` (scoped `serviceData`),
213
212
  `final-cta` (scoped `serviceData`), and `related-service` (scoped `service`, `index`) — same
214
- pattern as `ServicesSection`'s `cta`/`summary-link` slots. Only `sidebar-note` has no scoped
213
+ pattern as `ServiceSummary`'s `summary-link` slot. Only `sidebar-note` has no scoped
215
214
  data (it's plain content, e.g. patch-test wording).
216
215
  - `related-service`'s fallback content is a non-clickable thumbnail + title + price — pass the
217
216
  slot yourself to wrap it in a real link once you have a routing convention.
@@ -224,11 +223,11 @@ Section headings, sidebar labels, and CTA copy are **props**, not hardcoded stri
224
223
  `serviceData` — pass real `to` routes once your app's URL structure is known.
225
224
  - `headerTag` (default `"h1"`) controls only the hero title; `subheadingTag` (default `"h2"`)
226
225
  controls every other heading in the component (What Is It, Process, Ideal For, Aftercare, FAQs,
227
- final CTA) — unlike `ServicesSection`, which uses one `headerTag` prop for all of them. This
226
+ final CTA) — unlike `ServiceSummary`, which uses one `headerTag` prop for its (single) title heading. This
228
227
  component is meant to be the whole page's `<h1>`, so its subheadings need their own level.
229
228
  - The FAQ question is a hardcoded `<h3>` — if you set `subheadingTag="h3"`, the FAQ questions will
230
229
  sit at the same level as their own section heading rather than one below it.
231
230
  - Two-/three-column breakpoints (900px body, 500px ideal-for grid, 700px final CTA) use
232
231
  `@container` queries against the component's own width, not the viewport — they are not tokens
233
232
  since resizing them would need corresponding `minmax()`/`grid-template-columns` changes too, not
234
- just spacing (same rationale as `ServicesSection`'s `--services-section-grid-gap-desktop`).
233
+ just spacing (same rationale as `ServiceSummary`'s `--service-summary-grid-gap-desktop`).
@@ -294,7 +294,6 @@ watch(
294
294
  object-position: var(--service-detail-hero-image-position-small, bottom);
295
295
 
296
296
  @container (width >= 768px) {
297
- object-fit: contain;
298
297
  object-position: var(--service-detail-hero-image-position-medium, center);
299
298
  }
300
299
  }
@@ -82,7 +82,7 @@ const meta: Meta<typeof ServiceDetail> = {
82
82
  docs: {
83
83
  description: {
84
84
  component:
85
- "Renders a full service detail page: a full-bleed hero banner (breadcrumb, eyebrow, title, price/duration pills over the service image), a two-column body (long-form content plus a sticky sidebar booking card and related-services list), and a closing full-width CTA banner. Routing (breadcrumb links, book-cta, related-service links, final-cta) is delegated to the consumer via slots, same pattern as ServicesSection.",
85
+ "Renders a full service detail page: a full-bleed hero banner (breadcrumb, eyebrow, title, price/duration pills over the service image), a two-column body (long-form content plus a sticky sidebar booking card and related-services list), and a closing full-width CTA banner. Routing (breadcrumb links, book-cta, related-service links, final-cta) is delegated to the consumer via slots, same pattern as ServiceSummary.",
86
86
  },
87
87
  },
88
88
  },
@@ -299,7 +299,7 @@ export const CustomRelatedServiceLink: Story = {
299
299
  docs: {
300
300
  description: {
301
301
  story:
302
- "The related-service scoped slot replaces each related item's default (non-clickable) markup — use it to wrap the item in a real link, matching the pattern of ServicesSection's summary-link slot.",
302
+ "The related-service scoped slot replaces each related item's default (non-clickable) markup — use it to wrap the item in a real link, matching the pattern of ServiceSummary's summary-link slot.",
303
303
  },
304
304
  },
305
305
  },
@@ -0,0 +1,116 @@
1
+ # ServiceSummary — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ All `--service-summary-*` tokens are the stable override surface. Set them globally in a
6
+ theme file, scoped to a page wrapper, or per-instance via `styleClassPassthrough`.
7
+
8
+ ### Layout & sizing
9
+
10
+ | Token | Default | Controls |
11
+ |---|---|---|
12
+ | `--service-summary-grid-gap` | `2rem` | Gap between image and content columns below the 768px container breakpoint |
13
+ | `--service-summary-grid-gap-desktop` | `3rem` | Gap between columns at the 768px container breakpoint and above |
14
+ | `--service-summary-height-mobile` | `auto` | Height of the whole `.service-summary` row below the 768px container breakpoint. `auto` (the default) lets the row size to its content — the image sizes itself via `--service-summary-image-aspect-ratio` and the text column sizes to its own content. Set a fixed length to force both columns to share a common row height instead (the image then crops via `object-fit: cover` to fill it, and `alignment` positions the text within the leftover space) |
15
+ | `--service-summary-height-tablet` | `auto` (falls back to `--service-summary-height-mobile`) | Row height from the 768px container breakpoint |
16
+ | `--service-summary-height-desktop` | `auto` (falls back to `--service-summary-height-tablet`, then `-mobile`) | Row height from the 1024px container breakpoint |
17
+ | `--service-summary-image-aspect-ratio` | `1 / 1` | Aspect ratio of `.service-summary__image-wrapper` when its height isn't otherwise forced by a `--service-summary-height-*` override (i.e. the default, row-height-auto case) — has no visible effect once a row height override makes both dimensions definite |
18
+ | `--service-summary-image-padding-block-mobile` | `0` | Padding-block (top/bottom) inset around the image, inside `.service-summary__image-wrapper`, below the 768px container breakpoint — creates a visible frame/border effect since the image itself sizes to the wrapper's content box, not the padding box |
19
+ | `--service-summary-image-padding-block-tablet` | `0` (falls back to `-mobile`) | Padding-block from the 768px container breakpoint |
20
+ | `--service-summary-image-padding-block-desktop` | `0` (falls back to `-tablet`, then `-mobile`) | Padding-block from the 1024px container breakpoint |
21
+ | `--service-summary-image-padding-inline-mobile` | `0` | Padding-inline (left/right) inset around the image below the 768px container breakpoint |
22
+ | `--service-summary-image-padding-inline-tablet` | `0` (falls back to `-mobile`) | Padding-inline from the 768px container breakpoint |
23
+ | `--service-summary-image-padding-inline-desktop` | `0` (falls back to `-tablet`, then `-mobile`) | Padding-inline from the 1024px container breakpoint |
24
+ | `--service-summary-image-border-radius` | `0.8rem` | Corner rounding on the service image |
25
+ | `--service-summary-pills-gap` | `0.8rem` | Gap between the duration and price pills |
26
+ | `--service-summary-pills-margin-block-end` | `2rem` | Space below the pill row |
27
+
28
+ All breakpoints above (row height, image padding, and the two-column grid switch) are
29
+ **container queries** against `.service-summary`'s own inline size (`container-name:
30
+ service-summary`), not the viewport — this keeps the layout responsive to the space
31
+ `ServiceSummary` actually has, which matters when it sits next to a persistent side nav that a
32
+ viewport-based `@media` query can't see.
33
+
34
+ Below the 768px breakpoint the grid is a single stacked column (image above text), so a
35
+ `--service-summary-height-mobile` override sets a height for the *whole stack*, not just the
36
+ image — usually left `auto` on mobile and only set from `--service-summary-height-tablet` up,
37
+ where the layout is genuinely two columns side by side.
38
+
39
+ ### Pill colours
40
+
41
+ The duration/price pills are `DisplayPill` instances — `ServiceSummary` maps its own tokens onto
42
+ `DisplayPill`'s `--theme-pill-*` custom properties scoped to `.service-summary__pills`:
43
+
44
+ | Token | Default | Controls |
45
+ |---|---|---|
46
+ | `--service-summary-pill-bg` | `transparent` | Background of the price/duration pills |
47
+ | `--service-summary-pill-colour` | `currentColor` | Text colour of the pills |
48
+ | `--service-summary-pill-border-colour` | `currentColor` | Border colour of the pills |
49
+
50
+ For anything not covered by these (e.g. pill size, font weight), target `--theme-pill-*` directly
51
+ under `.service-summary__pills` — see [display-pill.md](../../../../../.claude/skills/components/display-pill.md).
52
+
53
+ ---
54
+
55
+ ## Global theming — recommended approach
56
+
57
+ Create `assets/styles/setup/07.components/service-summary.css` in the consuming app and
58
+ set tokens on `:root`. This applies to every `ServiceSummary` across the site.
59
+
60
+ ```css
61
+ /* assets/styles/setup/07.components/service-summary.css */
62
+ :root {
63
+ --service-summary-grid-gap: 2.4rem;
64
+ --service-summary-grid-gap-desktop: 4rem;
65
+ --service-summary-image-border-radius: 1.2rem;
66
+ }
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Page-scoped overrides
72
+
73
+ Override tokens for a specific section by scoping them under the page or layout wrapper.
74
+ No `:deep()` is required (component styles are unscoped).
75
+
76
+ ```css
77
+ /* In the consuming page's unscoped <style> block */
78
+ .our-services-page {
79
+ .service-summary {
80
+ --service-summary-pill-border-colour: var(--brand-accent);
81
+ }
82
+ }
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Per-instance overrides via styleClassPassthrough
88
+
89
+ Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct
90
+ visual style, pass a modifier class:
91
+
92
+ ```vue
93
+ <ServiceSummary :style-class-passthrough="['highlight-service']" :service-data="service" />
94
+ ```
95
+
96
+ ```css
97
+ .service-summary.highlight-service {
98
+ --service-summary-image-border-radius: 2rem;
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Notes
105
+
106
+ - `--service-summary-grid-gap-desktop` only applies at `768px` and above — the breakpoint
107
+ itself is not a token, since changing it would affect the grid's `minmax()` column sizing
108
+ too, not just spacing. The same 768px/1024px breakpoints are shared with the row-height
109
+ tokens above.
110
+ - The row-height tokens live on `.service-summary` itself (the whole row), not on
111
+ `.service-summary__image-wrapper` — `.service-summary__grid` and
112
+ `.service-summary__image-wrapper` both inherit it via `height: 100%`, so overriding the row
113
+ height also gives `alignment` real leftover space to position the text column within.
114
+ - `ServiceSummary` is summary-only — it has no "full mode". For a complete single-service page
115
+ (process, ideal-for list, FAQs, booking CTA), use
116
+ [ServiceDetail](../../../../../.claude/skills/components/service-detail.md) instead.
@@ -0,0 +1,183 @@
1
+ <template>
2
+ <component :is="tag" class="service-summary" :class="[elementClasses]" :aria-labelledby="ariaLabelledby">
3
+ <div class="service-summary__grid" :class="{ 'service-summary__grid--reverse': reverse }">
4
+ <div class="service-summary__image-wrapper">
5
+ <NuxtImg
6
+ :src="serviceData.image"
7
+ :alt="serviceData.title"
8
+ :loading="imageLoading"
9
+ :fetchpriority="imageFetchPriority"
10
+ class="service-summary__image"
11
+ />
12
+ </div>
13
+ <div class="service-summary__info-wrapper" :class="infoWrapperClasses">
14
+ <EyebrowText font-size="large" :text-content="serviceData.subtitle" />
15
+ <HeroText
16
+ :id="headingId"
17
+ :tag="headerTag"
18
+ font-size="title"
19
+ :text-content="[{ text: serviceData.title, styleClass: 'normal' }]"
20
+ :style-class-passthrough="['mb-20']"
21
+ />
22
+
23
+ <div class="service-summary__pills">
24
+ <DisplayPill :label="serviceData.duration" size="md" variant="neutral" />
25
+ <DisplayPill :label="`From ${serviceData.price}`" size="md" variant="neutral" />
26
+ </div>
27
+
28
+ <p class="page-body-normal">{{ serviceData.whatIsIt }}</p>
29
+
30
+ <slot name="summary-link" :service-data="serviceData"></slot>
31
+ </div>
32
+ </div>
33
+ </component>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ import type { Service } from "~/types/types.services";
38
+
39
+ interface Props {
40
+ tag?: "div" | "section" | "article" | "main";
41
+ headerTag?: "h1" | "h2" | "h3";
42
+ index?: number;
43
+ serviceData: Service;
44
+ alignment?: "start" | "center" | "end";
45
+ reverse?: boolean;
46
+ styleClassPassthrough?: string | string[];
47
+ }
48
+ const props = withDefaults(defineProps<Props>(), {
49
+ tag: "div",
50
+ headerTag: "h2",
51
+ index: 0,
52
+ alignment: "center",
53
+ reverse: false,
54
+ styleClassPassthrough: () => [],
55
+ });
56
+
57
+ const { headingId, ariaLabelledby } = useAriaLabelledById(() => props.tag);
58
+
59
+ const infoWrapperClasses = computed(() => ({
60
+ [`service-summary__info-wrapper--align-${props.alignment}`]: true,
61
+ }));
62
+
63
+ // Computed to return loading="lazy" if index is greater than 1, so that the first two summaries prioritise image loading, but if there are more than 2 summaries, the rest will lazy load their images to improve performance.
64
+ const imageLoading = computed(() => (props.index !== undefined && props.index > 1 ? "lazy" : "eager"));
65
+
66
+ // Only the first image (LCP candidate) gets fetchpriority="high" to reduce resource load delay.
67
+ const imageFetchPriority = computed(() => (props.index === 0 ? "high" : "auto"));
68
+
69
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
70
+
71
+ watch(
72
+ () => props.styleClassPassthrough,
73
+ () => {
74
+ resetElementClasses(props.styleClassPassthrough);
75
+ }
76
+ );
77
+ </script>
78
+
79
+ <style lang="css">
80
+ @layer components {
81
+ .service-summary {
82
+ container-type: inline-size;
83
+ container-name: service-summary;
84
+ height: var(--service-summary-height-mobile, auto);
85
+
86
+ @container service-summary (width >= 768px) {
87
+ height: var(--service-summary-height-tablet, var(--service-summary-height-mobile, auto));
88
+ }
89
+
90
+ @container service-summary (width >= 1024px) {
91
+ height: var(
92
+ --service-summary-height-desktop,
93
+ var(--service-summary-height-tablet, var(--service-summary-height-mobile, auto))
94
+ );
95
+ }
96
+
97
+ .service-summary__grid {
98
+ --_grid-gap: var(--service-summary-grid-gap, 2rem);
99
+
100
+ display: grid;
101
+ grid-template-columns: 1fr;
102
+ gap: var(--_grid-gap);
103
+ height: 100%;
104
+
105
+ @container service-summary (width >= 768px) {
106
+ --_grid-gap: var(--service-summary-grid-gap-desktop, 3rem);
107
+
108
+ grid-template-columns: repeat(auto-fit, minmax(246px, 1fr));
109
+
110
+ &.service-summary__grid--reverse {
111
+ .service-summary__image-wrapper {
112
+ /* The order: 2 on .service-summary__image-wrapper pushes it after the content div, since by default both children have order: 0 and source order wins — so the content div naturally sits first. */
113
+ order: 2;
114
+ }
115
+ }
116
+ }
117
+
118
+ .service-summary__info-wrapper {
119
+ display: grid;
120
+
121
+ &.service-summary__info-wrapper--align-start {
122
+ align-content: start;
123
+ }
124
+ &.service-summary__info-wrapper--align-center {
125
+ align-content: center;
126
+ }
127
+ &.service-summary__info-wrapper--align-end {
128
+ align-content: end;
129
+ }
130
+ }
131
+
132
+ .service-summary__image-wrapper {
133
+ height: 100%;
134
+ aspect-ratio: var(--service-summary-image-aspect-ratio, 1 / 1);
135
+ padding-block: var(--service-summary-image-padding-block-mobile, 0);
136
+ padding-inline: var(--service-summary-image-padding-inline-mobile, 0);
137
+ border-radius: var(--service-summary-image-border-radius, 0.8rem);
138
+ overflow: hidden;
139
+
140
+ @container service-summary (width >= 768px) {
141
+ padding-block: var(
142
+ --service-summary-image-padding-block-tablet,
143
+ var(--service-summary-image-padding-block-mobile, 0)
144
+ );
145
+ padding-inline: var(
146
+ --service-summary-image-padding-inline-tablet,
147
+ var(--service-summary-image-padding-inline-mobile, 0)
148
+ );
149
+ }
150
+
151
+ @container service-summary (width >= 1024px) {
152
+ padding-block: var(
153
+ --service-summary-image-padding-block-desktop,
154
+ var(--service-summary-image-padding-block-tablet, var(--service-summary-image-padding-block-mobile, 0))
155
+ );
156
+ padding-inline: var(
157
+ --service-summary-image-padding-inline-desktop,
158
+ var(--service-summary-image-padding-inline-tablet, var(--service-summary-image-padding-inline-mobile, 0))
159
+ );
160
+ }
161
+
162
+ .service-summary__image {
163
+ display: block;
164
+ width: 100%;
165
+ height: 100%;
166
+ object-fit: cover;
167
+ }
168
+ }
169
+
170
+ .service-summary__pills {
171
+ display: flex;
172
+ flex-wrap: wrap;
173
+ gap: var(--service-summary-pills-gap, 0.8rem);
174
+ margin-block-end: var(--service-summary-pills-margin-block-end, 2rem);
175
+
176
+ --theme-pill-bg: var(--service-summary-pill-bg, transparent);
177
+ --theme-pill-color: var(--service-summary-pill-colour, currentColor);
178
+ --theme-pill-border-color: var(--service-summary-pill-border-colour, currentColor);
179
+ }
180
+ }
181
+ }
182
+ }
183
+ </style>