srcdev-nuxt-components 9.1.58 → 9.1.59

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.
@@ -54,7 +54,10 @@
54
54
  "Bash(SRCDEV_STANDALONE=true npx vitest run app/components/01.atoms/toast)",
55
55
  "Bash(SRCDEV_STANDALONE=true npx vitest run app/components/02.molecules/alert-content)",
56
56
  "Bash(SRCDEV_STANDALONE=true npx vitest run app/components/01.atoms/prompt)",
57
- "Bash(git fetch *)"
57
+ "Bash(git fetch *)",
58
+ "Bash(python3 -m json.tool /Users/simoncornforth/websites/nuxt-components/.vscode/srcdev-component-services-section.code-snippets)",
59
+ "Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('/Users/simoncornforth/websites/nuxt-components/.vscode/srcdev-component-services-section.code-snippets','utf8'\\)\\); console.log\\('valid json'\\)\")",
60
+ "WebFetch(domain:luxury-locs-by-natasha-nuxt3.vercel.app)"
58
61
  ],
59
62
  "additionalDirectories": [
60
63
  "/Users/simoncornforth/websites/instepreflexology",
@@ -15,14 +15,40 @@ All routing and CTA decisions are delegated to the consumer via slots.
15
15
  |------|------|---------|----------|
16
16
  | `serviceData` | `Service` | — | **yes** |
17
17
  | `tag` | `"div" \| "section" \| "article" \| "main"` | `"div"` | no |
18
+ | `headerTag` | `"h1" \| "h2" \| "h3"` | `"h2"` | no |
18
19
  | `index` | `number` | `0` | no |
19
20
  | `isSummary` | `boolean` | `false` | no |
20
21
  | `summaryAlignment` | `"start" \| "center" \| "end"` | `"center"` | no |
21
22
  | `reverse` | `boolean` | `false` | no |
22
23
  | `durationIcon` | `string` | `"mdi:clock-time-four-outline"` | no |
23
24
  | `priceIcon` | `string` | `"mdi:currency-gbp"` | no |
25
+ | `processHeading` | `string` | `"The Process"` | no |
26
+ | `idealForHeading` | `string` | `"Ideal For"` | no |
27
+ | `maintenanceHeading` | `string` | `"Aftercare & Maintenance"` | no |
28
+ | `faqsHeading` | `string` | `"Frequently Asked Questions"` | no |
29
+ | `ctaHeading` | `string` | `"Ready to book your appointment?"` | no |
30
+ | `ctaBody` | `string` | `"Get in touch to book your appointment."` | no |
24
31
  | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
25
32
 
33
+ ### Heading and CTA copy props
34
+
35
+ All section subheadings and the default CTA panel's heading/body are props, not hardcoded
36
+ text — override them with wording specific to the consuming business:
37
+
38
+ ```vue
39
+ <ServicesSection
40
+ :service-data="service"
41
+ process-heading="How It Works"
42
+ cta-heading="Ready to book your colour appointment?"
43
+ cta-body="Mobile service across Bath — I come to you."
44
+ />
45
+ ```
46
+
47
+ Name the actual service in `ctaHeading` (rather than a generic phrase) if every service page
48
+ shares one CTA wrapper — a single hardcoded heading applied to every service previously caused
49
+ a real bug in production, where a "Colour" service page's CTA read "Ready to book your
50
+ **highlights** appointment?" regardless of which service was being viewed.
51
+
26
52
  ### Icon customisation
27
53
 
28
54
  The two icons in the price/duration row default to `mdi:clock-time-four-outline` and `mdi:currency-gbp`. Override them for a different currency or icon style:
@@ -51,8 +77,12 @@ Flips the image to the right column and content to the left (CSS `order: 2` on t
51
77
  |------|-----------|---------------|---------|
52
78
  | `summary-link` | `{ serviceData: Service }` | `isSummary` is `true` | Navigation link below the `whatIsIt` text in summary mode |
53
79
  | `cta` | `{ serviceData: Service }` | `isSummary` is `false` | CTA button/link inside the closing `GlassPanel` in full mode |
80
+ | `cta-panel` | `{ serviceData: Service, ctaHeading: string, ctaBody: string }` | `isSummary` is `false` | Replaces the entire default `GlassPanel` CTA block — use when a consumer needs a different component or layout, not just different copy |
54
81
 
55
- Both slots receive `serviceData` as a scoped prop.
82
+ All three slots receive `serviceData` as a scoped prop. `cta-panel`'s default content *is*
83
+ the `GlassPanel` + `ctaHeading`/`ctaBody` + `cta` slot combination described above — providing
84
+ `cta-panel` replaces that whole block, so the `ctaHeading`/`ctaBody` props and `cta` slot are
85
+ only useful if the `cta-panel` slot fallback (or the consumer's own override) still renders them.
56
86
 
57
87
  ## Summary mode usage
58
88
 
@@ -102,6 +132,21 @@ Both slots receive `serviceData` as a scoped prop.
102
132
  </ServicesSection>
103
133
  ```
104
134
 
135
+ ## Replacing the whole CTA panel (slot override)
136
+
137
+ Use `cta-panel` instead of `ctaHeading`/`ctaBody` when the CTA needs a different component
138
+ entirely — not just different text inside `GlassPanel`:
139
+
140
+ ```vue
141
+ <ServicesSection :service-data="service">
142
+ <template #cta-panel="{ serviceData, ctaHeading, ctaBody }">
143
+ <MyPromoBanner :heading="ctaHeading" :body="ctaBody">
144
+ <template #cta><InputButtonCore button-text="Enquire Now" href="/contact" /></template>
145
+ </MyPromoBanner>
146
+ </template>
147
+ </ServicesSection>
148
+ ```
149
+
105
150
  ## Rendering a list (summary mode)
106
151
 
107
152
  ```vue
@@ -122,9 +167,12 @@ Both slots receive `serviceData` as a scoped prop.
122
167
 
123
168
  ## Local style override scaffold
124
169
 
125
- When consuming this component, scaffold a style block using `styleClassPassthrough`. Delete the block if unused.
170
+ When consuming this component, prefer the `--services-section-*` CSS custom properties
171
+ documented in `CONSUMER-STYLING.md` (in the component's own folder) over raw class overrides.
172
+ For anything the tokens don't cover, scaffold a style block using `styleClassPassthrough`.
173
+ Delete the block if unused.
126
174
 
127
- See [component-local-style-override.md](../component-local-style-override.md) for the full pattern.
175
+ See [component-local-style-override.md](../component-local-style-override.md) for the general pattern.
128
176
 
129
177
  ```vue
130
178
  <ServicesSection :style-class-passthrough="['my-section']" :service-data="service">
@@ -138,8 +186,10 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
138
186
  ─────────────────────────────────────────────────────────────────── */
139
187
  .services-section {
140
188
  &.my-section {
141
- /* Geometry — image wrapper */
142
- /* .image-wrapper { border-radius: 1.2rem; } */
189
+ --services-section-image-border-radius: 1.2rem;
190
+
191
+ /* Deeper overrides target the BEM element classes directly, e.g.: */
192
+ /* .services-section__faq { } */
143
193
  }
144
194
  }
145
195
  </style>
@@ -150,6 +200,12 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
150
200
  - Component is auto-imported in Nuxt — no import needed.
151
201
  - The `Service` type is imported from `~/types/types.services`.
152
202
  - `summary-link` slot is guarded by `v-if="isSummary"` — it will not render in full mode even if provided.
153
- - `cta` slot lives inside a `v-if="!isSummary"` `GlassPanel` it will not render in summary mode.
203
+ - `cta` and `cta-panel` slots live inside `v-if="!isSummary"` — neither renders in summary mode.
204
+ - Internal element classes are BEM-namespaced: `services-section__image-wrapper`,
205
+ `services-section__info-wrapper`, `services-section__price-duration`,
206
+ `services-section__decorator`, `services-section__faq`, `services-section__faq-answer`,
207
+ `services-section__glass-panel`. If a consuming app has CSS overrides referencing older
208
+ unprefixed names (`.image-wrapper`, `.price-duration`, `.services-faq`, etc. — from before
209
+ this component's classnames were namespaced), those selectors need updating to match.
154
210
  - The section gets `aria-labelledby` automatically when `tag` is `"section"` or `"article"`, pointing to the internal heading id.
155
211
  - `summaryAlignment` only has effect when `isSummary` is `true` — it aligns the info-wrapper content vertically within the grid cell.
@@ -220,3 +220,52 @@ the component source folder for the full token reference.
220
220
  stacking-context constraint.
221
221
  - `--tab-nav-panel-bg` should match `--page-bg` for a seamless panel appearance in the collapsed
222
222
  state.
223
+
224
+ ## Known issue: SSR/hydration layout shift when the nav needs to collapse (2026-07-07)
225
+
226
+ **Symptom:** on first page load (hard reload/direct navigation), the header briefly renders taller
227
+ than its settled state, then shrinks — visibly shifting all content below it up. Most noticeable on
228
+ short pages (little content below the fold makes the jump occupy a large fraction of the viewport),
229
+ but the same underlying reflow happens on every page where the nav actually needs to collapse at
230
+ the viewport's width; it's just less visible on long pages or when the timing happens to resolve
231
+ before first paint.
232
+
233
+ **Root cause:** `useNavCollapse`'s `isLoaded` ref (and therefore `isCollapsed`) can only be
234
+ determined client-side — the server has no way to measure real DOM/viewport widths. So:
235
+
236
+ ```vue
237
+ <ul v-if="!isCollapsed || !isLoaded" ...>
238
+ ```
239
+
240
+ SSR and the very first client paint always render the **full, uncollapsed** `<ul>` (since
241
+ `!isLoaded` is true pre-mount), regardless of whether it will actually fit. Once mounted,
242
+ `checkOverflow()` runs (in `onMounted`, after `nextTick()`), and if the nav overflows, sets
243
+ `isCollapsed = true` — swapping the full list out for the shorter collapsed/burger variant. That
244
+ swap is what shrinks the header and shifts everything below it.
245
+
246
+ Confirmed via `PerformanceObserver({type: "layout-shift"})` plus DOM `MutationObserver` tracing in a
247
+ consuming app (`luxury-locs-by-natasha-nuxt3`): the class mutation on `.tab-navigation` (adding
248
+ `is-loaded is-animated`) lines up exactly with `.main-content`'s bounding-rect top moving by the
249
+ header's full height delta, at a variable point (roughly 100ms–800ms after first paint depending on
250
+ hydration timing) after first paint.
251
+
252
+ **Candidate fixes (not yet implemented — pick up later):**
253
+
254
+ 1. **CSS-only collapse instead of JS-measured overflow.** Use a `@container` or `@media` breakpoint
255
+ to switch between the full nav and the burger button, so the correct variant renders on the very
256
+ first paint (both server and client agree, no measurement round-trip needed). Loses the
257
+ "collapse exactly when it overflows, regardless of item count" precision of the current
258
+ JS-measured approach, gains zero-reflow correctness. Probably the most robust fix if a
259
+ reasonably conservative breakpoint can be chosen.
260
+ 2. **Reserve height instead of eliminating the swap.** Give the header a `min-height` matching the
261
+ taller (uncollapsed) state so the swap doesn't change the header's box size — trades a visible
262
+ "jump" for a slightly awkward gap during the pre-collapse window, but never moves content below
263
+ it. Cheap, but doesn't fix the actual root cause, just its visible symptom.
264
+ 3. **Suppress the flash rather than the shift**, e.g. hide the header's nav content entirely
265
+ (`visibility: hidden` or `opacity: 0`) until `isLoaded` is true, then fade in — avoids showing
266
+ the "wrong" state at all, but delays when the nav becomes visible/interactive, and would need
267
+ care to avoid its own CLS/accessibility issues (e.g. focus order, screen readers encountering
268
+ hidden nav).
269
+ 4. Investigate whether a plausible default guess for `isCollapsed` could be derived from something
270
+ SSR does have access to (e.g. a `Sec-CH-Viewport-Width` client hint header, if the deployment
271
+ target reliably sends one) — likely not worth the complexity/fragility versus option 1.
@@ -67,7 +67,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
67
67
  ├── services-card.md — ServicesCard props (incl. eyebrowConfig/heroConfig), actions slot, CSS tokens, page boilerplate
68
68
  ├── services-card-grid.md — ServicesCardGrid props, config pass-through, CSS tokens, full page boilerplate
69
69
  ├── services-section-grid.md — ServicesSectionGrid props, useAlternateReverse zigzag layout, page boilerplate
70
- ├── services-section.md — ServicesSection props, summary-link/cta slots, summary vs full mode
70
+ ├── services-section.md — ServicesSection props (incl. heading/CTA copy), summary-link/cta/cta-panel slots, summary vs full mode
71
71
  ├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API
72
72
  ├── stepper-list.md — StepperList dynamic slots (item-{n}/indicator-{n}), props, connector behaviour
73
73
  ├── expanding-panel.md — ExpandingPanel v-model, forceOpened, slots (summary/icon/content), ARIA wiring
@@ -4,15 +4,15 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
4
4
  "<div class="services-grid">
5
5
  <div class="services-section">
6
6
  <div class="services-section__grid">
7
- <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-installation.jpg 1x, /_ipx/_/images/locs-installation.jpg 2x" alt="Locs Installation" loading="eager" fetchpriority="high" class="image" src="/_ipx/_/images/locs-installation.jpg"></div>
8
- <div class="info-wrapper is-summary align-center">
7
+ <div class="services-section__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-installation.jpg 1x, /_ipx/_/images/locs-installation.jpg 2x" alt="Locs Installation" loading="eager" fetchpriority="high" class="services-section__image" src="/_ipx/_/images/locs-installation.jpg"></div>
8
+ <div class="services-section__info-wrapper services-section__info-wrapper--summary services-section__info-wrapper--align-center">
9
9
  <div class="eyebrow-text large">Subtitle for Locs Installation</div>
10
10
  <h2 class="hero-text mb-20 title axis-horizontal">
11
11
  <!--v-if--><span class="text-block-0 normal">Locs Installation</span>
12
12
  </h2>
13
- <div class="price-duration">
14
- <div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline decorator" aria-hidden="true"></span><span>60 mins</span></div>
15
- <div class="flex-row"><span class="iconify i-mdi:currency-gbp decorator" aria-hidden="true"></span><span>£50</span></div>
13
+ <div class="services-section__price-duration">
14
+ <div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline services-section__decorator" aria-hidden="true"></span><span>60 mins</span></div>
15
+ <div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
16
16
  </div>
17
17
  <!--v-if-->
18
18
  <!--v-if-->
@@ -31,15 +31,15 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
31
31
  </div>
32
32
  <div class="services-section">
33
33
  <div class="services-section__grid">
34
- <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-retwist.jpg 1x, /_ipx/_/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="eager" fetchpriority="auto" class="image" src="/_ipx/_/images/locs-retwist.jpg"></div>
35
- <div class="info-wrapper is-summary align-center">
34
+ <div class="services-section__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-retwist.jpg 1x, /_ipx/_/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="eager" fetchpriority="auto" class="services-section__image" src="/_ipx/_/images/locs-retwist.jpg"></div>
35
+ <div class="services-section__info-wrapper services-section__info-wrapper--summary services-section__info-wrapper--align-center">
36
36
  <div class="eyebrow-text large">Subtitle for Locs Retwist</div>
37
37
  <h2 class="hero-text mb-20 title axis-horizontal">
38
38
  <!--v-if--><span class="text-block-0 normal">Locs Retwist</span>
39
39
  </h2>
40
- <div class="price-duration">
41
- <div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline decorator" aria-hidden="true"></span><span>60 mins</span></div>
42
- <div class="flex-row"><span class="iconify i-mdi:currency-gbp decorator" aria-hidden="true"></span><span>£50</span></div>
40
+ <div class="services-section__price-duration">
41
+ <div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline services-section__decorator" aria-hidden="true"></span><span>60 mins</span></div>
42
+ <div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
43
43
  </div>
44
44
  <!--v-if-->
45
45
  <!--v-if-->
@@ -58,15 +58,15 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
58
58
  </div>
59
59
  <div class="services-section">
60
60
  <div class="services-section__grid">
61
- <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/colour-treatment.jpg 1x, /_ipx/_/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" fetchpriority="auto" class="image" src="/_ipx/_/images/colour-treatment.jpg"></div>
62
- <div class="info-wrapper is-summary align-center">
61
+ <div class="services-section__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/colour-treatment.jpg 1x, /_ipx/_/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" fetchpriority="auto" class="services-section__image" src="/_ipx/_/images/colour-treatment.jpg"></div>
62
+ <div class="services-section__info-wrapper services-section__info-wrapper--summary services-section__info-wrapper--align-center">
63
63
  <div class="eyebrow-text large">Subtitle for Colour Treatment</div>
64
64
  <h2 class="hero-text mb-20 title axis-horizontal">
65
65
  <!--v-if--><span class="text-block-0 normal">Colour Treatment</span>
66
66
  </h2>
67
- <div class="price-duration">
68
- <div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline decorator" aria-hidden="true"></span><span>60 mins</span></div>
69
- <div class="flex-row"><span class="iconify i-mdi:currency-gbp decorator" aria-hidden="true"></span><span>£50</span></div>
67
+ <div class="services-section__price-duration">
68
+ <div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline services-section__decorator" aria-hidden="true"></span><span>60 mins</span></div>
69
+ <div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
70
70
  </div>
71
71
  <!--v-if-->
72
72
  <!--v-if-->
@@ -0,0 +1,149 @@
1
+ # ServicesSection — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ All `--services-section-*` 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
+ | `--services-section-grid-gap` | `2rem` | Gap between image and content columns below the 768px breakpoint |
13
+ | `--services-section-grid-gap-desktop` | `3rem` | Gap between columns at 768px and above |
14
+ | `--services-section-image-border-radius` | `0.8rem` | Corner rounding on the service image |
15
+ | `--services-section-price-duration-gap` | `3rem` | Gap between the duration and price rows |
16
+ | `--services-section-price-duration-item-gap` | `0.8rem` | Gap between icon and text within a single row |
17
+ | `--services-section-price-duration-margin-block-end` | `2rem` | Space below the price/duration row |
18
+ | `--services-section-decorator-size` | `2rem` | Width/height of the duration and price icons |
19
+ | `--services-section-faq-margin-block-end` | `2.4rem` | Space below the FAQ accordion |
20
+ | `--services-section-faq-answer-line-height` | `1.6` | Line height of FAQ answer text |
21
+
22
+ ### Colours
23
+
24
+ | Token | Default | Controls |
25
+ |---|---|---|
26
+ | `--services-section-faq-divider-color` | `currentColor` | Border colour between FAQ accordion items |
27
+ | `--services-section-faq-divider-opacity` | `0.7` | Opacity of each FAQ accordion item's border |
28
+
29
+ ---
30
+
31
+ ## Text content — props, not CSS
32
+
33
+ Section headings and the default CTA panel copy are **props**, not hardcoded strings, so
34
+ there is nothing to override in CSS for these:
35
+
36
+ | Prop | Default |
37
+ |---|---|
38
+ | `processHeading` | `"The Process"` |
39
+ | `idealForHeading` | `"Ideal For"` |
40
+ | `maintenanceHeading` | `"Aftercare & Maintenance"` |
41
+ | `faqsHeading` | `"Frequently Asked Questions"` |
42
+ | `ctaHeading` | `"Ready to book your appointment?"` |
43
+ | `ctaBody` | `"Get in touch to book your appointment."` |
44
+
45
+ ```vue
46
+ <ServicesSection
47
+ :service-data="service"
48
+ cta-heading="Ready to book your locs consultation?"
49
+ cta-body="Mobile service across Bath — I come to you."
50
+ />
51
+ ```
52
+
53
+ If the CTA needs a different component entirely (not just different text), replace it with
54
+ the `cta-panel` scoped slot instead of styling around the default `GlassPanel`:
55
+
56
+ ```vue
57
+ <ServicesSection :service-data="service">
58
+ <template #cta-panel="{ serviceData, ctaHeading, ctaBody }">
59
+ <MyCustomPanel :heading="ctaHeading" :body="ctaBody">
60
+ <template #cta><button>Enquire Now</button></template>
61
+ </MyCustomPanel>
62
+ </template>
63
+ </ServicesSection>
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Global theming — recommended approach
69
+
70
+ Create `assets/styles/setup/07.components/services-section.css` in the consuming app and
71
+ set tokens on `:root`. This applies to every `ServicesSection` across the site.
72
+
73
+ ```css
74
+ /* assets/styles/setup/07.components/services-section.css */
75
+ :root {
76
+ --services-section-grid-gap: 2.4rem;
77
+ --services-section-grid-gap-desktop: 4rem;
78
+ --services-section-image-border-radius: 1.2rem;
79
+ --services-section-faq-divider-color: var(--brand-border);
80
+ }
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Page-scoped overrides
86
+
87
+ Override tokens for a specific section by scoping them under the page or layout wrapper.
88
+ No `:deep()` is required (component styles are unscoped).
89
+
90
+ ```css
91
+ /* In the consuming page's unscoped <style> block */
92
+ .our-services-page {
93
+ .services-section {
94
+ --services-section-decorator-size: 2.4rem;
95
+ --services-section-faq-answer-line-height: 1.8;
96
+ }
97
+ }
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Per-instance overrides via styleClassPassthrough
103
+
104
+ Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct
105
+ visual style, pass a modifier class:
106
+
107
+ ```vue
108
+ <ServicesSection :style-class-passthrough="['highlight-service']" :service-data="service" />
109
+ ```
110
+
111
+ ```css
112
+ .services-section.highlight-service {
113
+ --services-section-image-border-radius: 2rem;
114
+ --services-section-faq-divider-color: var(--color-accent);
115
+ }
116
+ ```
117
+
118
+ ---
119
+
120
+ ## FAQ accordion styling
121
+
122
+ The `AccordianCore` inside this component receives `services-section__faq` via
123
+ `styleClassPassthrough`. To target the accordion's own internals (summary, icon rotation,
124
+ content padding), style through that class rather than reaching into `AccordianCore`
125
+ directly — see `.services-section__faq` in the component's own `<style>` block for the
126
+ selectors already scoped this way (`.accordian-item.expanding-panel`,
127
+ `.expanding-panel-summary`, `.services-section__faq-answer`).
128
+
129
+ ```css
130
+ .services-section {
131
+ .services-section__faq {
132
+ .expanding-panel-details .expanding-panel-summary {
133
+ padding-block: 1.6rem;
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Notes
142
+
143
+ - `--services-section-grid-gap-desktop` only applies at `768px` and above — the breakpoint
144
+ itself is not a token, since changing it would affect the grid's `minmax()` column sizing
145
+ too, not just spacing.
146
+ - The CTA panel is rendered inside a default `<slot name="cta-panel">` — passing that slot
147
+ bypasses `GlassPanel` and all of its tokens (`--glass-panel-*`) entirely, along with the
148
+ `ctaHeading`/`ctaBody` props, which are handed to the slot for reuse rather than applied
149
+ automatically.
@@ -1,10 +1,16 @@
1
1
  <template>
2
2
  <component :is="tag" class="services-section" :class="[elementClasses]" :aria-labelledby="ariaLabelledby">
3
3
  <div class="services-section__grid" :class="{ 'services-section__grid--reverse': reverse }">
4
- <div class="image-wrapper">
5
- <NuxtImg :src="serviceData.image" :alt="serviceData.title" :loading="imageLoading" :fetchpriority="imageFetchPriority" class="image" />
4
+ <div class="services-section__image-wrapper">
5
+ <NuxtImg
6
+ :src="serviceData.image"
7
+ :alt="serviceData.title"
8
+ :loading="imageLoading"
9
+ :fetchpriority="imageFetchPriority"
10
+ class="services-section__image"
11
+ />
6
12
  </div>
7
- <div class="info-wrapper" :class="infoWrapperClasses">
13
+ <div class="services-section__info-wrapper" :class="infoWrapperClasses">
8
14
  <EyebrowText font-size="large" :text-content="serviceData.subtitle" />
9
15
  <HeroText
10
16
  :tag="headerTag"
@@ -18,13 +24,13 @@
18
24
  :style-class-passthrough="['mb-20']"
19
25
  />
20
26
 
21
- <div class="price-duration">
27
+ <div class="services-section__price-duration">
22
28
  <div class="flex-row">
23
- <Icon :name="durationIcon" class="decorator" />
29
+ <Icon :name="durationIcon" class="services-section__decorator" />
24
30
  <span>{{ serviceData.duration }}</span>
25
31
  </div>
26
32
  <div class="flex-row">
27
- <Icon :name="priceIcon" class="decorator" />
33
+ <Icon :name="priceIcon" class="services-section__decorator" />
28
34
  <span>{{ serviceData.price }}</span>
29
35
  </div>
30
36
  </div>
@@ -50,7 +56,7 @@
50
56
  :tag="headerTag"
51
57
  axis="horizontal"
52
58
  font-size="subheading"
53
- :text-content="[{ text: 'The Process ', styleClass: 'normal' }]"
59
+ :text-content="[{ text: processHeading, styleClass: 'normal' }]"
54
60
  :style-class-passthrough="['mb-20']"
55
61
  />
56
62
 
@@ -72,7 +78,7 @@
72
78
  :tag="headerTag"
73
79
  axis="horizontal"
74
80
  font-size="subheading"
75
- :text-content="[{ text: 'Ideal For', styleClass: 'normal' }]"
81
+ :text-content="[{ text: idealForHeading, styleClass: 'normal' }]"
76
82
  :style-class-passthrough="['mb-20']"
77
83
  />
78
84
 
@@ -90,7 +96,7 @@
90
96
  :tag="headerTag"
91
97
  axis="horizontal"
92
98
  font-size="subheading"
93
- :text-content="[{ text: 'Aftercare &amp; Maintenance', styleClass: 'normal' }]"
99
+ :text-content="[{ text: maintenanceHeading, styleClass: 'normal' }]"
94
100
  :style-class-passthrough="['mb-20']"
95
101
  />
96
102
 
@@ -101,7 +107,7 @@
101
107
  :tag="headerTag"
102
108
  axis="horizontal"
103
109
  font-size="subheading"
104
- :text-content="[{ text: 'Frequently Asked Questions', styleClass: 'normal' }]"
110
+ :text-content="[{ text: faqsHeading, styleClass: 'normal' }]"
105
111
  :style-class-passthrough="['mb-20']"
106
112
  />
107
113
 
@@ -110,7 +116,7 @@
110
116
  id="faq"
111
117
  :item-count="serviceData.faqs.length"
112
118
  :name="`faq-${useId()}`"
113
- :style-class-passthrough="['services-faq']"
119
+ :style-class-passthrough="['services-section__faq']"
114
120
  >
115
121
  <template v-for="(_, key) in serviceData.faqs" :key="`summary-${key}`" #[`accordian-${key}-summary`]>
116
122
  {{ serviceData.faqs[key]?.question }}
@@ -119,21 +125,29 @@
119
125
  <Icon name="mdi:chevron-down" />
120
126
  </template>
121
127
  <template v-for="(_, key) in serviceData.faqs" :key="`content-${key}`" #[`accordian-${key}-content`]>
122
- <p>{{ serviceData.faqs[key]?.answer }}</p>
128
+ <p class="services-section__faq-answer">{{ serviceData.faqs[key]?.answer }}</p>
123
129
  </template>
124
130
  </AccordianCore>
125
131
 
126
- <GlassPanel v-if="!isSummary" :style-class-passthrough="['p-24']">
127
- <HeroText
128
- tag="h2"
129
- axis="horizontal"
130
- font-size="subheading"
131
- :text-content="[{ text: 'Ready to book your highlights appointment?', styleClass: 'normal' }]"
132
- :style-class-passthrough="['mbs-0', 'mbe-20']"
133
- />
134
- <p class="page-body-normal">Mobile service across Bath — I come to you.</p>
135
- <slot name="cta" :service-data="serviceData"></slot>
136
- </GlassPanel>
132
+ <slot
133
+ v-if="!isSummary"
134
+ name="cta-panel"
135
+ :service-data="serviceData"
136
+ :cta-heading="ctaHeading"
137
+ :cta-body="ctaBody"
138
+ >
139
+ <GlassPanel :style-class-passthrough="['services-section__glass-panel', 'p-24']">
140
+ <HeroText
141
+ tag="h2"
142
+ axis="horizontal"
143
+ font-size="subheading"
144
+ :text-content="[{ text: ctaHeading, styleClass: 'normal' }]"
145
+ :style-class-passthrough="['mbs-0', 'mbe-20']"
146
+ />
147
+ <p class="page-body-normal">{{ ctaBody }}</p>
148
+ <slot name="cta" :service-data="serviceData"></slot>
149
+ </GlassPanel>
150
+ </slot>
137
151
  </div>
138
152
  </div>
139
153
  </component>
@@ -152,6 +166,12 @@ interface Props {
152
166
  reverse?: boolean;
153
167
  durationIcon?: string;
154
168
  priceIcon?: string;
169
+ processHeading?: string;
170
+ idealForHeading?: string;
171
+ maintenanceHeading?: string;
172
+ faqsHeading?: string;
173
+ ctaHeading?: string;
174
+ ctaBody?: string;
155
175
  styleClassPassthrough?: string | string[];
156
176
  }
157
177
  const props = withDefaults(defineProps<Props>(), {
@@ -162,6 +182,12 @@ const props = withDefaults(defineProps<Props>(), {
162
182
  summaryAlignment: "center",
163
183
  durationIcon: "mdi:clock-time-four-outline",
164
184
  priceIcon: "mdi:currency-gbp",
185
+ processHeading: "The Process",
186
+ idealForHeading: "Ideal For",
187
+ maintenanceHeading: "Aftercare & Maintenance",
188
+ faqsHeading: "Frequently Asked Questions",
189
+ ctaHeading: "Ready to book your appointment?",
190
+ ctaBody: "Get in touch to book your appointment.",
165
191
  styleClassPassthrough: () => [],
166
192
  });
167
193
 
@@ -169,10 +195,10 @@ const { ariaLabelledby } = useAriaLabelledById(() => props.tag);
169
195
 
170
196
  const infoWrapperClasses = computed(() => {
171
197
  return {
172
- "is-summary": props.isSummary,
173
- "align-start": props.isSummary && props.summaryAlignment === "start",
174
- "align-center": props.isSummary && props.summaryAlignment === "center",
175
- "align-end": props.isSummary && props.summaryAlignment === "end",
198
+ "services-section__info-wrapper--summary": props.isSummary,
199
+ "services-section__info-wrapper--align-start": props.isSummary && props.summaryAlignment === "start",
200
+ "services-section__info-wrapper--align-center": props.isSummary && props.summaryAlignment === "center",
201
+ "services-section__info-wrapper--align-end": props.isSummary && props.summaryAlignment === "end",
176
202
  };
177
203
  });
178
204
 
@@ -199,44 +225,47 @@ watch(
199
225
  container-name: services-section;
200
226
 
201
227
  .services-section__grid {
228
+ --_grid-gap: var(--services-section-grid-gap, 2rem);
229
+
202
230
  display: grid;
203
231
  grid-template-columns: 1fr;
204
- gap: 2rem;
232
+ gap: var(--_grid-gap);
205
233
 
206
234
  @media (width >= 768px) {
235
+ --_grid-gap: var(--services-section-grid-gap-desktop, 3rem);
236
+
207
237
  grid-template-columns: repeat(auto-fit, minmax(446px, 1fr));
208
- gap: 3rem;
209
238
 
210
239
  &.services-section__grid--reverse {
211
- .image-wrapper {
212
- /* The order: 2 on .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. */
240
+ .services-section__image-wrapper {
241
+ /* The order: 2 on .services-section__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. */
213
242
  order: 2;
214
243
  }
215
244
  }
216
245
  }
217
246
 
218
- .info-wrapper {
219
- &.is-summary {
247
+ .services-section__info-wrapper {
248
+ &.services-section__info-wrapper--summary {
220
249
  display: grid;
221
250
 
222
- &.align-start {
251
+ &.services-section__info-wrapper--align-start {
223
252
  align-content: start;
224
253
  }
225
- &.align-center {
254
+ &.services-section__info-wrapper--align-center {
226
255
  align-content: center;
227
256
  }
228
- &.align-end {
257
+ &.services-section__info-wrapper--align-end {
229
258
  align-content: end;
230
259
  }
231
260
  }
232
261
  }
233
262
 
234
- .image-wrapper {
263
+ .services-section__image-wrapper {
235
264
  align-self: start;
236
- border-radius: 8px;
265
+ border-radius: var(--services-section-image-border-radius, 0.8rem);
237
266
  overflow: hidden;
238
267
 
239
- .image {
268
+ .services-section__image {
240
269
  display: block;
241
270
  width: 100%;
242
271
  height: 100%;
@@ -244,45 +273,38 @@ watch(
244
273
  }
245
274
  }
246
275
 
247
- .price-duration {
276
+ .services-section__price-duration {
248
277
  display: flex;
249
278
  flex-direction: row;
250
- gap: 3rem;
279
+ gap: var(--services-section-price-duration-gap, 3rem);
251
280
  align-items: center;
252
- margin-block-end: 2rem;
281
+ margin-block-end: var(--services-section-price-duration-margin-block-end, 2rem);
253
282
 
254
283
  > div {
255
284
  display: flex;
256
285
  flex-direction: row;
257
- gap: 0.8rem;
286
+ gap: var(--services-section-price-duration-item-gap, 0.8rem);
258
287
  align-items: center;
259
288
 
260
- .decorator {
261
- width: 2rem;
262
- height: 2rem;
289
+ .services-section__decorator {
290
+ width: var(--services-section-decorator-size, 2rem);
291
+ height: var(--services-section-decorator-size, 2rem);
263
292
  }
264
293
  }
265
294
  }
266
295
 
267
- .glass-card {
268
- background: rgba(255, 255, 255, 0.6);
269
- border-radius: 1rem;
270
- backdrop-filter: blur(10px);
271
- }
272
-
273
- .services-faq {
274
- margin-block-end: 24px;
296
+ .services-section__faq {
297
+ margin-block-end: var(--services-section-faq-margin-block-end, 2.4rem);
275
298
 
276
299
  &.display-accordian {
277
300
  max-width: none;
278
- /* margin: 0; */
279
301
 
280
302
  .accordian-item.expanding-panel {
281
- border-block-end: 1px solid currentColor;
282
- opacity: 0.7;
303
+ border-block-end: 1px solid var(--services-section-faq-divider-color, currentColor);
304
+ opacity: var(--services-section-faq-divider-opacity, 0.7);
283
305
 
284
306
  &:first-child {
285
- border-block-start: 1px solid currentColor;
307
+ border-block-start: 1px solid var(--services-section-faq-divider-color, currentColor);
286
308
  }
287
309
 
288
310
  .expanding-panel-details .expanding-panel-summary {
@@ -290,7 +312,9 @@ watch(
290
312
  }
291
313
 
292
314
  .expanding-panel-content .inner {
293
- /* padding-block-end: 1.2rem; */
315
+ .services-section__faq-answer {
316
+ line-height: var(--services-section-faq-answer-line-height, 1.6);
317
+ }
294
318
  }
295
319
  }
296
320
  }
@@ -1,4 +1,9 @@
1
1
  import ServicesSection from "../ServicesSection.vue";
2
+ import PageRow from "../../../../01.atoms/page-row/PageRow.vue";
3
+ import TextBlock from "../../../../01.atoms/text-block/TextBlock.vue";
4
+ import EyebrowText from "../../../../01.atoms/text-blocks/eyebrow-text/EyebrowText.vue";
5
+ import HeroText from "../../../../01.atoms/text-blocks/hero-text/HeroText.vue";
6
+ import InputButtonCore from "../../../../05.forms/input-button/InputButtonCore.vue";
2
7
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
8
  import type { Service } from "~/types/types.services";
4
9
 
@@ -37,6 +42,30 @@ const meta: Meta<typeof ServicesSection> = {
37
42
  control: { type: "text" },
38
43
  description: "Icon name for the price row (any Iconify icon)",
39
44
  },
45
+ processHeading: {
46
+ control: { type: "text" },
47
+ description: "Heading text for the process section",
48
+ },
49
+ idealForHeading: {
50
+ control: { type: "text" },
51
+ description: "Heading text for the ideal-for section",
52
+ },
53
+ maintenanceHeading: {
54
+ control: { type: "text" },
55
+ description: "Heading text for the maintenance/aftercare section",
56
+ },
57
+ faqsHeading: {
58
+ control: { type: "text" },
59
+ description: "Heading text for the FAQs section",
60
+ },
61
+ ctaHeading: {
62
+ control: { type: "text" },
63
+ description: "Heading text inside the default CTA glass panel — ignored if the cta-panel slot is used",
64
+ },
65
+ ctaBody: {
66
+ control: { type: "text" },
67
+ description: "Body text inside the default CTA glass panel — ignored if the cta-panel slot is used",
68
+ },
40
69
  styleClassPassthrough: {
41
70
  control: "object",
42
71
  description: "Additional CSS classes applied to the root element",
@@ -56,7 +85,7 @@ const meta: Meta<typeof ServicesSection> = {
56
85
  docs: {
57
86
  description: {
58
87
  component:
59
- "Renders a single service as a two-column section: image on one side, detailed content on the other. Two modes: summary (compact, with navigation slot) and full (complete content with CTA slot). Icon names for the price/duration row are configurable via `durationIcon` and `priceIcon` props.",
88
+ "Renders a single service as a two-column section: image on one side, detailed content on the other. Two modes: summary (compact, with navigation slot) and full (complete content with CTA slot). Icon names for the price/duration row are configurable via `durationIcon` and `priceIcon` props. Section headings and the CTA panel copy are configurable via props (`processHeading`, `idealForHeading`, `maintenanceHeading`, `faqsHeading`, `ctaHeading`, `ctaBody`); the whole CTA panel can be replaced with the `cta-panel` scoped slot.",
60
89
  },
61
90
  },
62
91
  },
@@ -68,65 +97,77 @@ type Story = StoryObj<typeof ServicesSection>;
68
97
  // ─── Fixtures ─────────────────────────────────────────────────────────────────
69
98
 
70
99
  const sampleService: Service = {
71
- slug: "locs-installation",
72
- category: "locs",
73
- title: "Locs Installation",
74
- subtitle: "Start your loc journey",
75
- price: "£120",
76
- duration: "34 hours",
77
- image: "https://picsum.photos/seed/locs/800/600",
78
- shortDescription: "Professional loc installation tailored to your hair type.",
100
+ slug: "colour",
101
+ category: "colour",
102
+ title: "Full Colour",
103
+ subtitle: "Rich, even colour from root to tip",
104
+ price: "From £75",
105
+ duration: "1.52.5 hours",
106
+ image: "https://picsum.photos/seed/colour/800/600",
107
+ shortDescription: "Full colour coverage in permanent, semi-permanent, or demi-permanent formulas.",
79
108
  longDescription:
80
- "Our loc installation service covers everything from initial consultation through to the finished style. We work with all hair types and lengths to create beautiful, long-lasting locs.",
109
+ "Full colour service covering the entire head in a single, even shade — whether you're covering grey, going darker for the season, or refreshing your natural colour. We work with permanent, semi-permanent, and demi-permanent formulas depending on the level of commitment and colour result you're after.",
81
110
  heroHeading: [
82
- { text: "Why choose ", styleClass: "normal" },
83
- { text: "locs?", styleClass: "accent" },
111
+ { text: "Full colour vs. ", styleClass: "normal" },
112
+ { text: "highlights?", styleClass: "accent" },
84
113
  ],
85
114
  whatIsIt:
86
- "Locs are a protective hairstyle formed by allowing hair to naturally matt and coil over time. They require minimal daily maintenance and are suitable for all hair types.",
115
+ "Full colour applies a single, even shade across the entire head, unlike highlights or balayage which lighten select sections. It's the go-to choice for full grey coverage, a complete colour change, or a rich, uniform base tone.",
87
116
  process: [
88
- "Consultation to assess hair type and length",
89
- "Scalp preparation and cleanse",
90
- "Sectioning and palm-rolling",
91
- "Drying and finishing",
117
+ "Consultation and shade selection",
118
+ "Strand test for new clients",
119
+ "Even application, root to tip",
120
+ "Processing time — 25–45 minutes depending on the formula",
121
+ "Rinse and conditioning treatment",
122
+ "Blow-dry and finish",
92
123
  ],
93
124
  idealFor: [
94
- "Anyone looking for a low-maintenance protective style",
95
- "Those with natural hair wanting a permanent style",
96
- "People seeking a versatile long-term hairstyle",
125
+ "Full grey coverage",
126
+ "Going darker for the season",
127
+ "Fashion colours needing a uniform base",
128
+ "Root touch-ups between full appointments",
97
129
  ],
98
130
  maintenance:
99
- "Re-twist every 4–6 weeks to maintain neat roots. Keep scalp moisturised and protect hair at night with a satin bonnet.",
131
+ "Root regrowth typically becomes visible after 4–6 weeks. Book a maintenance appointment around this point, and use a colour-safe shampoo and conditioner to protect vibrancy between visits.",
100
132
  faqs: [
101
133
  {
102
- question: "How long do locs take to mature?",
103
- answer: "Locs typically take 12–18 months to fully mature depending on hair type and texture.",
134
+ question: "What's the difference between permanent and semi-permanent colour?",
135
+ answer:
136
+ "Permanent colour lifts and deposits pigment for long-lasting, fade-resistant results. Semi-permanent colour sits on the hair's surface and gradually washes out over 4–8 weeks — a lower-commitment option if you're not sure about a change.",
104
137
  },
105
138
  {
106
- question: "Can I wash my locs?",
107
- answer: "Yes — regular washing is encouraged. We recommend a residue-free shampoo every 1–2 weeks.",
139
+ question: "Can full colour lighten my hair significantly?",
140
+ answer:
141
+ "Full colour can lighten by a few shades, but for a dramatic lift (going several shades lighter) a lightening service or highlights are usually a better fit — we'll advise at consultation.",
142
+ },
143
+ {
144
+ question: "How effective is full colour at covering grey?",
145
+ answer:
146
+ "Very effective — permanent colour gives the most complete, long-lasting grey coverage. We'll match a shade that blends naturally with your regrowth pattern.",
108
147
  },
109
148
  ],
110
- seoTitle: "Locs Installation | Professional Hair Locs Service",
149
+ seoTitle: "Full Colour | Luxury Locs by Natasha",
111
150
  seoDescription:
112
- "Professional locs installation service. Book a consultation today and start your loc journey with an experienced stylist.",
151
+ "Full colour hair service permanent, semi-permanent, and demi-permanent options for grey coverage, seasonal colour changes, and fashion shades. Mobile service across Bath.",
113
152
  };
114
153
 
115
154
  // ─── Stories ──────────────────────────────────────────────────────────────────
116
155
 
117
156
  export const FullMode: Story = {
118
157
  name: "Full Mode",
158
+ args: {
159
+ ctaHeading: "Ready to book your appointment?",
160
+ ctaBody: "Mobile service across Bath — I come to you.",
161
+ },
119
162
  render: (args) => ({
120
- components: { ServicesSection },
163
+ components: { ServicesSection, InputButtonCore },
121
164
  setup() {
122
165
  return { args, sampleService };
123
166
  },
124
167
  template: `
125
168
  <ServicesSection v-bind="args" :service-data="sampleService">
126
169
  <template #cta>
127
- <button style="margin-top:1.6rem;padding:1.2rem 2.4rem;background:#333;color:#fff;border:none;border-radius:0.4rem;cursor:pointer;">
128
- Book Now
129
- </button>
170
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
130
171
  </template>
131
172
  </ServicesSection>
132
173
  `,
@@ -135,7 +176,7 @@ export const FullMode: Story = {
135
176
  docs: {
136
177
  description: {
137
178
  story:
138
- "Full mode (isSummary: false) — renders all content including process, ideal-for, FAQs, and the CTA GlassPanel.",
179
+ "Full mode (isSummary: false) — renders all content including a six-step process, ideal-for list, FAQs, and the CTA panel, matching the real service detail page content shape.",
139
180
  },
140
181
  },
141
182
  },
@@ -213,3 +254,116 @@ export const CustomIcons: Story = {
213
254
  },
214
255
  },
215
256
  };
257
+
258
+ export const CustomTextAndCta: Story = {
259
+ name: "Custom Headings & CTA Copy",
260
+ args: {
261
+ processHeading: "How It Works",
262
+ idealForHeading: "Who Is This For",
263
+ maintenanceHeading: "Aftercare",
264
+ faqsHeading: "FAQs",
265
+ ctaHeading: "Ready to book your colour appointment?",
266
+ ctaBody: "Mobile service across Bath — I come to you.",
267
+ },
268
+ render: (args) => ({
269
+ components: { ServicesSection, InputButtonCore },
270
+ setup() {
271
+ return { args, sampleService };
272
+ },
273
+ template: `
274
+ <ServicesSection v-bind="args" :service-data="sampleService">
275
+ <template #cta>
276
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
277
+ </template>
278
+ </ServicesSection>
279
+ `,
280
+ }),
281
+ parameters: {
282
+ docs: {
283
+ description: {
284
+ story:
285
+ "Section headings and CTA panel copy overridden via props — useful for wording specific to the consuming business rather than the library defaults. Note the heading names the actual service, avoiding the old bug where every service page said 'highlights appointment' regardless of which service it was.",
286
+ },
287
+ },
288
+ },
289
+ };
290
+
291
+ export const RealisticPageContext: Story = {
292
+ name: "Realistic Page Context",
293
+ args: {
294
+ ctaHeading: "Ready to book your appointment?",
295
+ ctaBody: "Mobile service across Bath — I come to you.",
296
+ },
297
+ render: (args) => ({
298
+ components: { ServicesSection, PageRow, TextBlock, EyebrowText, HeroText, InputButtonCore },
299
+ setup() {
300
+ return { args, sampleService };
301
+ },
302
+ template: `
303
+ <div>
304
+ <PageRow tag="div" variant="content" :style-class-passthrough="['mbe-20']">
305
+ <TextBlock tag="div" :style-class-passthrough="['page-lead']">
306
+ <EyebrowText font-size="large" text-content="Services" />
307
+ <HeroText
308
+ tag="h1"
309
+ axis="vertical"
310
+ font-size="display"
311
+ :text-content="[
312
+ { text: 'Expert colour & ', styleClass: 'normal' },
313
+ { text: 'styling', styleClass: 'accent' },
314
+ { text: 'at your door', styleClass: 'normal' },
315
+ ]"
316
+ :style-class-passthrough="['mb-20']"
317
+ />
318
+ </TextBlock>
319
+ </PageRow>
320
+
321
+ <PageRow tag="div" variant="content" :style-class-passthrough="['mbe-20']">
322
+ <ServicesSection v-bind="args" :service-data="sampleService">
323
+ <template #cta>
324
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
325
+ </template>
326
+ </ServicesSection>
327
+ </PageRow>
328
+ </div>
329
+ `,
330
+ }),
331
+ parameters: {
332
+ docs: {
333
+ description: {
334
+ story:
335
+ "Matches the real service detail page (app/pages/services/[slug].vue in the luxury-locs-by-natasha-nuxt3 project): a page hero (EyebrowText + HeroText inside PageRow/TextBlock) followed by ServicesSection with a real 'Book now' CTA button. Use this story to sanity-check layout and spacing changes against the actual consuming page rather than the bare component.",
336
+ },
337
+ },
338
+ },
339
+ };
340
+
341
+ export const CustomCtaPanel: Story = {
342
+ name: "Custom CTA Panel (slot override)",
343
+ render: (args) => ({
344
+ components: { ServicesSection },
345
+ setup() {
346
+ return { args, sampleService };
347
+ },
348
+ template: `
349
+ <ServicesSection v-bind="args" :service-data="sampleService">
350
+ <template #cta-panel="{ serviceData }">
351
+ <div style="padding:2.4rem;border:2px dashed #333;border-radius:1rem;text-align:center;">
352
+ <p style="margin:0 0 1.2rem;">Fully custom CTA block for {{ serviceData.title }} — not a GlassPanel at all.</p>
353
+ <button style="padding:1.2rem 2.4rem;background:#333;color:#fff;border:none;border-radius:0.4rem;cursor:pointer;">
354
+ Enquire Now
355
+ </button>
356
+ </div>
357
+ </template>
358
+ </ServicesSection>
359
+ `,
360
+ }),
361
+ parameters: {
362
+ docs: {
363
+ description: {
364
+ story:
365
+ "The cta-panel scoped slot replaces the default GlassPanel entirely — use when a consumer needs a different component or layout for the closing call-to-action, not just different copy.",
366
+ },
367
+ },
368
+ },
369
+ };
@@ -211,4 +211,75 @@ describe("ServicesSection", () => {
211
211
  expect(wrapper.classes()).not.toContain("original");
212
212
  expect(wrapper.classes()).toContain("updated");
213
213
  });
214
+
215
+ // ─── Headings ───────────────────────────────────────────────────────────
216
+
217
+ it("renders default section headings", async () => {
218
+ const wrapper = await mountSuspended(ServicesSection, {
219
+ props: { serviceData: mockService },
220
+ });
221
+ expect(wrapper.text()).toContain("The Process");
222
+ expect(wrapper.text()).toContain("Ideal For");
223
+ expect(wrapper.text()).toContain("Aftercare & Maintenance");
224
+ expect(wrapper.text()).toContain("Frequently Asked Questions");
225
+ });
226
+
227
+ it("overrides section headings via props", async () => {
228
+ const wrapper = await mountSuspended(ServicesSection, {
229
+ props: {
230
+ serviceData: mockService,
231
+ processHeading: "How It Works",
232
+ idealForHeading: "Who Is This For",
233
+ maintenanceHeading: "Aftercare",
234
+ faqsHeading: "FAQs",
235
+ },
236
+ });
237
+ expect(wrapper.text()).toContain("How It Works");
238
+ expect(wrapper.text()).toContain("Who Is This For");
239
+ expect(wrapper.text()).toContain("Aftercare");
240
+ expect(wrapper.text()).toContain("FAQs");
241
+ });
242
+
243
+ // ─── CTA panel ──────────────────────────────────────────────────────────
244
+
245
+ it("renders default CTA heading and body", async () => {
246
+ const wrapper = await mountSuspended(ServicesSection, {
247
+ props: { serviceData: mockService },
248
+ });
249
+ expect(wrapper.text()).toContain("Ready to book your appointment?");
250
+ expect(wrapper.text()).toContain("Get in touch to book your appointment.");
251
+ });
252
+
253
+ it("overrides CTA heading and body via props", async () => {
254
+ const wrapper = await mountSuspended(ServicesSection, {
255
+ props: {
256
+ serviceData: mockService,
257
+ ctaHeading: "Book your locs consultation",
258
+ ctaBody: "Mobile service across Bath — I come to you.",
259
+ },
260
+ });
261
+ expect(wrapper.text()).toContain("Book your locs consultation");
262
+ expect(wrapper.text()).toContain("Mobile service across Bath — I come to you.");
263
+ });
264
+
265
+ it("replaces the whole CTA panel via the cta-panel slot", async () => {
266
+ const wrapper = await mountSuspended(ServicesSection, {
267
+ props: { serviceData: mockService },
268
+ slots: {
269
+ "cta-panel": '<div class="test-custom-cta">Custom CTA</div>',
270
+ },
271
+ });
272
+ expect(wrapper.find(".test-custom-cta").exists()).toBe(true);
273
+ expect(wrapper.text()).not.toContain("Ready to book your appointment?");
274
+ });
275
+
276
+ it("does not render cta-panel slot in summary mode", async () => {
277
+ const wrapper = await mountSuspended(ServicesSection, {
278
+ props: { serviceData: mockService, isSummary: true },
279
+ slots: {
280
+ "cta-panel": '<div class="test-custom-cta">Custom CTA</div>',
281
+ },
282
+ });
283
+ expect(wrapper.find(".test-custom-cta").exists()).toBe(false);
284
+ });
214
285
  });
@@ -3,15 +3,15 @@
3
3
  exports[`ServicesSection > renders correct HTML structure 1`] = `
4
4
  "<div class="services-section">
5
5
  <div class="services-section__grid">
6
- <div class="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="image" src="/_ipx/_/images/test.jpg"></div>
7
- <div class="info-wrapper">
6
+ <div class="services-section__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="services-section__image" src="/_ipx/_/images/test.jpg"></div>
7
+ <div class="services-section__info-wrapper">
8
8
  <div class="eyebrow-text large">A subtitle</div>
9
9
  <h2 class="hero-text mb-20 title axis-horizontal">
10
10
  <!--v-if--><span class="text-block-0 normal">Test Service</span>
11
11
  </h2>
12
- <div class="price-duration">
13
- <div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline decorator" aria-hidden="true"></span><span>60 mins</span></div>
14
- <div class="flex-row"><span class="iconify i-mdi:currency-gbp decorator" aria-hidden="true"></span><span>£50</span></div>
12
+ <div class="services-section__price-duration">
13
+ <div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline services-section__decorator" aria-hidden="true"></span><span>60 mins</span></div>
14
+ <div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
15
15
  </div>
16
16
  <p class="page-body-normal">Long description text</p>
17
17
  <h2 class="hero-text mb-20 subheading axis-horizontal">
@@ -60,23 +60,23 @@ exports[`ServicesSection > renders correct HTML structure 1`] = `
60
60
  <h2 class="hero-text mb-20 subheading axis-horizontal">
61
61
  <!--v-if--><span class="text-block-0 normal">Frequently Asked Questions</span>
62
62
  </h2>
63
- <div class="display-accordian services-faq" id="faq">
63
+ <div class="display-accordian services-section__faq" id="faq">
64
64
  <div class="expanding-panel accordian-item" icon-size="medium">
65
65
  <details class="expanding-panel-details" name="faq-v-0-0-0">
66
66
  <summary id="id-faq-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-faq-v-0-0-0-content" aria-expanded="false"><span class="label-wrapper">FAQ question?</span><span class="icon-wrapper"><span class="iconify i-mdi:chevron-down" aria-hidden="true"></span></span></summary>
67
67
  </details>
68
68
  <div id="id-faq-v-0-0-0-content" class="expanding-panel-content" aria-labelledby="id-faq-v-0-0-0-trigger" role="region">
69
69
  <div class="inner">
70
- <p>FAQ answer.</p>
70
+ <p class="services-section__faq-answer">FAQ answer.</p>
71
71
  </div>
72
72
  </div>
73
73
  </div>
74
74
  </div>
75
- <div class="glass-panel p-24">
75
+ <div class="glass-panel services-section__glass-panel p-24">
76
76
  <h2 class="hero-text mbs-0 mbe-20 subheading axis-horizontal">
77
- <!--v-if--><span class="text-block-0 normal">Ready to book your highlights appointment?</span>
77
+ <!--v-if--><span class="text-block-0 normal">Ready to book your appointment?</span>
78
78
  </h2>
79
- <p class="page-body-normal">Mobile service across Bath I come to you.</p>
79
+ <p class="page-body-normal">Get in touch to book your appointment.</p>
80
80
  </div>
81
81
  </div>
82
82
  </div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.1.58",
4
+ "version": "9.1.59",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",