srcdev-nuxt-components 9.3.8 → 9.3.9

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.
@@ -10,6 +10,17 @@ related-services list), and a closing full-width CTA banner. The process steps r
10
10
  markup — Ideal For and FAQs are still bespoke (2-column card grid, plain Q&A list) since neither
11
11
  matches an existing molecule as directly.
12
12
 
13
+ The hero is a [GridStack](grid-stack.md) of two [PageRow](page-row.md) layers — an `image` layer
14
+ (default `variant="full"`, prop `heroImageVariant`) behind a `content` layer (default
15
+ `variant="content"`, prop `heroContentVariant`) — so the photo can bleed to whatever width
16
+ `ServiceDetail` itself is placed at (true edge-to-edge if there's no content-constraining wrapper
17
+ around it) while the breadcrumb/title/pills stay aligned to the page's normal content column by
18
+ default, independent of how wide the photo bleeds. The two-column body and the closing full-width
19
+ CTA banner are each their own `PageRow` too, with their own variant props (`bodyVariant`,
20
+ `finalCtaVariant`). All four variant props default to the values described above — override them
21
+ if a particular page wants a section to sit in a different track (e.g.
22
+ `heroContentVariant="inset-content"` for a narrower hero text column).
23
+
13
24
  It is a different shape from [ServicesSection](services-section.md): `ServicesSection` renders
14
25
  image-beside-content (used both as a compact summary card in a list and, in full mode, as a
15
26
  same-page detail block); `ServiceDetail` is meant to *be* the whole page for one service —
@@ -26,6 +37,10 @@ rendered.
26
37
  | `headerTag` | `"h1" \| "h2" \| "h3"` | `"h1"` | no |
27
38
  | `subheadingTag` | `"h2" \| "h3"` | `"h2"` | no |
28
39
  | `breadcrumbItems` | `BreadcrumbItem[]` | auto-built from `serviceData` | no |
40
+ | `heroImageVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"full"` | no |
41
+ | `heroContentVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"content"` | no |
42
+ | `bodyVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"content"` | no |
43
+ | `finalCtaVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"content"` | no |
29
44
  | `processHeading` | `string` | `"The Process"` | no |
30
45
  | `idealForHeading` | `string` | `"Ideal For"` | no |
31
46
  | `maintenanceHeading` | `string` | `"Aftercare & Maintenance"` | no |
@@ -80,7 +95,7 @@ Defaults to a plain, non-linked two-item trail built from `serviceData.category`
80
95
 
81
96
  | Slot | Slot props | Purpose |
82
97
  |------|-----------|---------|
83
- | `book-cta` | `{ serviceData: Service }` | Booking button inside the sidebar's booking card |
98
+ | `book-cta` | `{ serviceData: Service }` | Booking button inside the sidebar's booking card — its row (`v-if="$slots['book-cta']"`) only renders when content is passed |
84
99
  | `sidebar-note` | — | Freeform note under the booking button (e.g. patch-test wording) — empty by default, deliberately not hardcoded since this library also serves non-hair-and-beauty apps |
85
100
  | `related-service` | `{ service: Service, index: number }` | Replaces one related-service item's whole default (non-clickable) thumbnail/title/price markup — use to wrap it in a real link |
86
101
  | `final-cta` | `{ serviceData: Service }` | Button in the closing full-width CTA banner |
@@ -150,6 +165,16 @@ pattern, and `CONSUMER-STYLING.md` in the component's own folder for the full to
150
165
  - Internal element classes are BEM-namespaced under `service-detail__*` (`service-detail__hero`,
151
166
  `service-detail__process`, `service-detail__sidebar`, `service-detail__related`, etc.) — see
152
167
  `CONSUMER-STYLING.md` for the full list alongside their tokens.
168
+ - `bodyVariant` (default `"content"`) sets the `PageRow` variant wrapping the two-column body, and
169
+ `finalCtaVariant` (default `"content"`) sets the `PageRow` variant wrapping the closing CTA
170
+ banner — a consumer no longer needs to wrap `ServiceDetail` in its own outer `PageRow` purely to
171
+ constrain either section's width, though wrapping it in a wider outer `PageRow` is still the way
172
+ to unlock a genuinely full-bleed hero image (see the GridStack/PageRow note above).
173
+ - `heroImageVariant` (default `"full"`) and `heroContentVariant` (default `"content"`) are each a
174
+ `PageRow` variant in their own right, not a wrapper around one — a `PageRow`'s own box always
175
+ spans the full width of its parent regardless of variant, only its children default into the
176
+ variant's grid column, so `.service-detail__hero-overlay` (the `heroContentVariant` element) can
177
+ carry the full-bleed scrim while its child still lands in the configured column.
153
178
  - The section gets `aria-labelledby` automatically when `tag` is `"section"`, `"article"`, or
154
179
  `"aside"`, pointing at the id `ServiceDetail` binds to its own title `HeroText` internally —
155
180
  same mechanism as `ServicesSection`. `tag="main"` renders a `<main>` element but is never
@@ -37,6 +37,16 @@
37
37
  "</ServiceDetail>"
38
38
  ]
39
39
  },
40
+ "SRCDEV ServiceDetail Layout Variants": {
41
+ "description": "ServiceDetail PageRow variant props — hero image/content layers, the two-column body, and the closing CTA banner",
42
+ "scope": "vue,html",
43
+ "body": [
44
+ "hero-image-variant=\"$1full\"",
45
+ "hero-content-variant=\"$2content\"",
46
+ "body-variant=\"$3content\"",
47
+ "final-cta-variant=\"$4content\""
48
+ ]
49
+ },
40
50
  "SRCDEV ServiceDetail Related Service Link Slot": {
41
51
  "description": "ServiceDetail related-service scoped slot — wraps each related item in a real link",
42
52
  "scope": "vue,html",
@@ -1,14 +1,47 @@
1
1
  # ServiceDetail — Consumer Styling Guide
2
2
 
3
+ ## Layout building blocks
4
+
5
+ The hero banner is a [GridStack](../../../../../.claude/skills/components/grid-stack.md) with two
6
+ stacked layers — an `image` layer (the service photo) behind a `content` layer (the scrim +
7
+ breadcrumb/title/pills). Each layer's root element is itself a
8
+ [PageRow](../../../../../.claude/skills/components/page-row.md): `.service-detail__hero-image-row`
9
+ (prop `heroImageVariant`, default `"full"`) wraps the photo, and `.service-detail__hero-overlay`
10
+ (prop `heroContentVariant`, default `"content"`) *is* the scrim/alignment element, not a wrapper
11
+ around one — a `PageRow`'s own box always spans the full width of its parent regardless of variant,
12
+ only its children default into the variant's grid column, so the one element can carry the
13
+ full-bleed scrim while its child (`.service-detail__hero-content`) still gets pulled to the
14
+ configured column. This means the photo bleeds to whatever width `.service-detail` itself is allowed
15
+ (edge-to-edge if `ServiceDetail` is placed without a content-constraining wrapper around it), while
16
+ the breadcrumb/title/pills always stay pulled into the page's normal content-width column,
17
+ regardless of how wide the photo bleeds. `--service-detail-hero-padding` now only controls
18
+ **block** (vertical) padding on the scrim — horizontal alignment of the text is handled entirely by
19
+ `.service-detail__hero-overlay`'s own `PageRow` grid, not by padding, so it can't fight the page's
20
+ content-grid gutters.
21
+
22
+ The two-column body (main content + sticky sidebar) is wrapped in its own `PageRow`, whose variant
23
+ is a prop: `bodyVariant` (`"full" | "popout" | "content" | "inset-content"`, default `"content"`).
24
+ The closing full-width CTA banner is wrapped the same way via `finalCtaVariant` (same type, same
25
+ `"content"` default). This means a consumer no longer needs to wrap `ServiceDetail` itself in an
26
+ outer `PageRow` purely to constrain either section's width — pass `body-variant`/`final-cta-variant`
27
+ directly. It's still fine (and necessary for a genuine full-bleed hero) to place `ServiceDetail`
28
+ inside an outer, wider `PageRow` — see
29
+ [services-section.md](../../../../../.claude/skills/components/services-section.md)-style nesting
30
+ notes in [page-row.md](../../../../../.claude/skills/components/page-row.md) for how nested page-rows
31
+ compose.
32
+
3
33
  ## Public token API
4
34
 
5
35
  ### Hero banner
6
36
 
7
37
  | Token | Default | Controls |
8
38
  |---|---|---|
9
- | `--service-detail-hero-border-radius` | `0.8rem` | Corner rounding on the hero image |
10
- | `--service-detail-hero-min-height` | `32rem` | Minimum height of the hero banner |
11
- | `--service-detail-hero-padding` | `2.4rem` | Padding around the breadcrumb/title/pills content |
39
+ | `--service-detail-hero-border-radius` | `0.8rem` | Corner rounding on the hero (clips the image/content layers — set to `0` for a true edge-to-edge bleed hero) |
40
+ | `--service-detail-hero-content-align` | `end` | `align-content` of the content overlay grid — where the breadcrumb/title/pills sit within the hero's block axis (`end` = bottom-anchored) |
41
+ | `--service-detail-hero-min-height-mobile` | `32rem` | Hero banner height below the `768px` container breakpoint. The hero image always fills this height (`object-fit`/`object-position` control cropping) |
42
+ | `--service-detail-hero-min-height-tablet` | `36rem` | Hero banner height from the `768px` container breakpoint |
43
+ | `--service-detail-hero-min-height-desktop` | `42rem` | Hero banner height from the `1024px` container breakpoint |
44
+ | `--service-detail-hero-padding` | `3.2rem` | **Block-axis (vertical)** padding around the breadcrumb/title/pills content — horizontal alignment comes from the nested content `PageRow`, not this token |
12
45
  | `--service-detail-hero-scrim` | `linear-gradient(0deg, rgb(0 0 0 / 70%) 0%, rgb(0 0 0 / 10%) 60%, transparent 100%)` | Overlay gradient behind the hero text, for legibility over the image |
13
46
  | `--service-detail-hero-text-colour` | `white` | Colour of the breadcrumb, eyebrow, and title over the hero image |
14
47
  | `--service-detail-breadcrumb-margin-block-end` | `0.8rem` | Space below the breadcrumb |
@@ -16,6 +49,16 @@
16
49
  | `--service-detail-hero-pill-bg` | `transparent` | Background of the price/duration pills (via `DisplayPill`'s `--theme-pill-bg`) |
17
50
  | `--service-detail-hero-pill-border-colour` | `currentColor` | Border colour of the price/duration pills |
18
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
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.
57
+
58
+ For anything not covered by these (e.g. the `full`/`content` variant tracks themselves), target
59
+ `--page-row-*` tokens under `.service-detail__hero-image-row`/`.service-detail__hero-overlay` — see
60
+ [page-row.md](../../../../../.claude/skills/components/page-row.md).
61
+
19
62
  ### Body layout
20
63
 
21
64
  | Token | Default | Controls |
@@ -25,7 +68,9 @@
25
68
  | `--service-detail-main-section-gap` | `2.4rem` | Gap between stacked sections in the main column |
26
69
 
27
70
  The body becomes two columns at a **900px container width** (`@container`, not viewport) — the
28
- main column is flexible, the sidebar is `minmax(28rem, 34rem)`.
71
+ main column is flexible, the sidebar is `minmax(28rem, 34rem)`. The body's own breakout width is
72
+ controlled by the `bodyVariant` prop (default `"content"`), not a token — see Layout building
73
+ blocks above.
29
74
 
30
75
  ### Process / Ideal For / FAQs
31
76
 
@@ -55,6 +100,7 @@ target `--stepper-list-*` directly under `.service-detail__process` — see
55
100
  | `--service-detail-ideal-for-item-border-radius` | `0.4rem` | Card corner rounding |
56
101
  | `--service-detail-ideal-for-icon-size` | `1.6rem` | Icon size |
57
102
  | `--service-detail-ideal-for-icon-colour` | `var(--colour-text-accent)` | Icon colour |
103
+ | `--service-detail-ideal-for-icon-transform` | `translateY(0.4rem)` | Vertical nudge on the icon to optically align it with the first line of text |
58
104
  | `--service-detail-faq-padding-block` | `1.6rem` | Vertical padding per FAQ entry |
59
105
  | `--service-detail-faq-divider-colour` | `currentColor` | Divider line between FAQ entries |
60
106
  | `--service-detail-faq-question-font-size` | `1.6rem` | FAQ question font size |
@@ -75,6 +121,8 @@ The ideal-for grid becomes 2 columns at a **500px container width**.
75
121
  | `--service-detail-sidebar-row-gap` | `1rem` | Gap between a row's label and value |
76
122
  | `--service-detail-sidebar-row-padding-block` | `1rem` | Vertical padding per price/duration/location row |
77
123
  | `--service-detail-sidebar-row-divider-colour` | `currentColor` | Divider line above each price/duration/location row |
124
+ | `--service-detail-sidebar-row-book-cta-padding-block` | `2rem` | Vertical padding on the `book-cta` slot's own row (only renders when the slot has content) |
125
+ | `--service-detail-sidebar-row-book-cta-justify-content` | `end` | Horizontal alignment of the `book-cta` slot content within its row |
78
126
  | `--service-detail-sidebar-row-label-font-size` | `1.2rem` | Font size of a row's label (e.g. "Price") |
79
127
  | `--service-detail-sidebar-note-font-size` | `1.2rem` | Font size of the `sidebar-note` slot content |
80
128
  | `--service-detail-sidebar-note-margin-block-start` | `1.2rem` | Space above the `sidebar-note` slot content |
@@ -87,6 +135,7 @@ The ideal-for grid becomes 2 columns at a **500px container width**.
87
135
  | `--service-detail-related-image-size` | `5.6rem` | Width/height of a related-service thumbnail |
88
136
  | `--service-detail-related-image-border-radius` | `0.4rem` | Corner rounding of a related-service thumbnail |
89
137
  | `--service-detail-related-price-font-size` | `1.2rem` | Font size of a related item's price |
138
+ | `--service-detail-related-price-margin-block-start` | `0.6rem` | Space between a related item's title and its price |
90
139
  | `--service-detail-muted-opacity` | `0.7` | Opacity shared by every secondary/muted text element (sidebar label, sidebar note, related price, final CTA body) |
91
140
 
92
141
  The booking card is a `GlassPanel` — its own `--glass-panel-*` tokens also apply.
@@ -96,11 +145,13 @@ The booking card is a `GlassPanel` — its own `--glass-panel-*` tokens also app
96
145
  | Token | Default | Controls |
97
146
  |---|---|---|
98
147
  | `--service-detail-final-cta-gap` | `1.6rem` | Gap between the copy and the `final-cta` slot content |
99
- | `--service-detail-final-cta-margin-block-start` | `4rem` | Space above the banner |
100
- | `--service-detail-final-cta-padding-block-start` | `3.2rem` | Padding above the banner content, below its divider |
148
+ | `--service-detail-final-cta-margin-block-start` | `3.2rem` | Space above the banner |
149
+ | `--service-detail-final-cta-padding-block` | `3.2rem 3.2rem` | Padding (block-start block-end) around the banner content, below its divider |
101
150
  | `--service-detail-final-cta-divider-colour` | `currentColor` | Divider line above the banner |
102
151
 
103
- The banner becomes a single row (copy left, CTA right) at a **700px container width**.
152
+ The banner becomes a single row (copy left, CTA right) at a **700px container width**. Its own
153
+ breakout width is controlled by the `finalCtaVariant` prop (default `"content"`), not a token —
154
+ see Layout building blocks above.
104
155
 
105
156
  ---
106
157
 
@@ -167,6 +218,8 @@ Section headings, sidebar labels, and CTA copy are **props**, not hardcoded stri
167
218
  - The `location` row and the entire "You May Also Like" block only render when `location` /
168
219
  `relatedServices` are actually passed — neither exists on the shared `Service` type, so nothing
169
220
  breaks for consumer apps that don't have this data.
221
+ - The `book-cta` slot's own sidebar row also only renders when that slot actually has content
222
+ (`v-if="$slots['book-cta']"`) — it no longer reserves an empty row when the slot is unused.
170
223
  - `breadcrumbItems` defaults to a plain, non-linked `[category, title]` trail built from
171
224
  `serviceData` — pass real `to` routes once your app's URL structure is known.
172
225
  - `headerTag` (default `"h1"`) controls only the hero title; `subheadingTag` (default `"h2"`)
@@ -1,165 +1,181 @@
1
1
  <template>
2
2
  <component :is="tag" class="service-detail" :class="[elementClasses]" :aria-labelledby="ariaLabelledby">
3
- <div class="service-detail__hero">
4
- <NuxtImg
5
- :src="serviceData.image"
6
- :alt="serviceData.title"
7
- :loading="imageLoading"
8
- :fetchpriority="imageFetchPriority"
9
- class="service-detail__hero-image"
10
- />
11
- <div class="service-detail__hero-overlay">
12
- <div class="service-detail__hero-content">
13
- <Breadcrumb :items="resolvedBreadcrumbItems" class="service-detail__breadcrumb" />
14
- <EyebrowText font-size="large" :text-content="serviceData.subtitle" />
3
+ <GridStack tag="div" class="service-detail__hero">
4
+ <template #image>
5
+ <PageRow tag="div" :variant="heroImageVariant" class="service-detail__hero-image-row">
6
+ <NuxtImg
7
+ :src="serviceData.image"
8
+ :alt="serviceData.title"
9
+ :loading="imageLoading"
10
+ :fetchpriority="imageFetchPriority"
11
+ class="service-detail__hero-image"
12
+ />
13
+ </PageRow>
14
+ </template>
15
+ <template #content>
16
+ <PageRow tag="div" :variant="heroContentVariant" class="service-detail__hero-overlay">
17
+ <div class="service-detail__hero-content">
18
+ <Breadcrumb :items="resolvedBreadcrumbItems" class="service-detail__breadcrumb" />
19
+ <EyebrowText font-size="large" :text-content="serviceData.subtitle" />
20
+ <HeroText
21
+ :id="headingId"
22
+ :tag="headerTag"
23
+ font-size="display"
24
+ :text-content="[{ text: serviceData.title, styleClass: 'normal' }]"
25
+ :style-class-passthrough="['mb-20']"
26
+ />
27
+ <div class="service-detail__hero-pills">
28
+ <DisplayPill :label="serviceData.duration" size="md" variant="neutral" />
29
+ <DisplayPill :label="`From ${serviceData.price}`" size="md" variant="neutral" />
30
+ </div>
31
+ </div>
32
+ </PageRow>
33
+ </template>
34
+ </GridStack>
35
+
36
+ <PageRow tag="div" :variant="bodyVariant" class="service-detail__body-row">
37
+ <div class="service-detail__body">
38
+ <div class="service-detail__main">
39
+ <p class="page-body-normal">{{ serviceData.longDescription }}</p>
40
+
15
41
  <HeroText
16
- :id="headingId"
17
- :tag="headerTag"
18
- font-size="display"
19
- :text-content="[{ text: serviceData.title, styleClass: 'normal' }]"
42
+ :tag="subheadingTag"
43
+ axis="horizontal"
44
+ font-size="subheading"
45
+ :text-content="serviceData.heroHeading"
20
46
  :style-class-passthrough="['mb-20']"
21
47
  />
22
- <div class="service-detail__hero-pills">
23
- <DisplayPill :label="serviceData.duration" size="md" variant="neutral" />
24
- <DisplayPill :label="`From ${serviceData.price}`" size="md" variant="neutral" />
25
- </div>
26
- </div>
27
- </div>
28
- </div>
29
-
30
- <div class="service-detail__body">
31
- <div class="service-detail__main">
32
- <p class="page-body-normal">{{ serviceData.longDescription }}</p>
33
-
34
- <HeroText
35
- :tag="subheadingTag"
36
- axis="horizontal"
37
- font-size="subheading"
38
- :text-content="serviceData.heroHeading"
39
- :style-class-passthrough="['mb-20']"
40
- />
41
- <p class="page-body-normal">{{ serviceData.whatIsIt }}</p>
42
-
43
- <HeroText
44
- :tag="subheadingTag"
45
- axis="horizontal"
46
- font-size="subheading"
47
- :text-content="[{ text: processHeading, styleClass: 'normal' }]"
48
- :style-class-passthrough="['mb-20']"
49
- />
50
- <StepperList
51
- tag="ol"
52
- indicator-alignment="top"
53
- indicator-variant="circle"
54
- :connected="true"
55
- :item-count="serviceData.process.length"
56
- class="service-detail__process"
57
- >
58
- <template v-for="(step, i) in serviceData.process" :key="i" #[`item-${i}`]>
59
- <p class="page-body-normal">{{ step }}</p>
60
- </template>
61
- </StepperList>
62
-
63
- <HeroText
64
- :tag="subheadingTag"
65
- axis="horizontal"
66
- font-size="subheading"
67
- :text-content="[{ text: idealForHeading, styleClass: 'normal' }]"
68
- :style-class-passthrough="['mb-20']"
69
- />
70
- <ul class="service-detail__ideal-for">
71
- <li v-for="(item, i) in serviceData.idealFor" :key="i" class="service-detail__ideal-for-item">
72
- <Icon name="mdi:diamond-stone" class="service-detail__ideal-for-icon" />
73
- <p class="page-body-normal">{{ item }}</p>
74
- </li>
75
- </ul>
76
-
77
- <HeroText
78
- :tag="subheadingTag"
79
- axis="horizontal"
80
- font-size="subheading"
81
- :text-content="[{ text: maintenanceHeading, styleClass: 'normal' }]"
82
- :style-class-passthrough="['mb-20']"
83
- />
84
- <p class="page-body-normal">{{ serviceData.maintenance }}</p>
85
-
86
- <HeroText
87
- :tag="subheadingTag"
88
- axis="horizontal"
89
- font-size="subheading"
90
- :text-content="[{ text: faqsHeading, styleClass: 'normal' }]"
91
- :style-class-passthrough="['mb-20']"
92
- />
93
- <div class="service-detail__faqs">
94
- <div v-for="(faq, i) in serviceData.faqs" :key="i" class="service-detail__faq">
95
- <h3 class="service-detail__faq-question">{{ faq.question }}</h3>
96
- <p class="page-body-normal">{{ faq.answer }}</p>
48
+ <p class="page-body-normal">{{ serviceData.whatIsIt }}</p>
49
+
50
+ <HeroText
51
+ :tag="subheadingTag"
52
+ axis="horizontal"
53
+ font-size="subheading"
54
+ :text-content="[{ text: processHeading, styleClass: 'normal' }]"
55
+ :style-class-passthrough="['mb-20']"
56
+ />
57
+ <StepperList
58
+ tag="ol"
59
+ indicator-alignment="top"
60
+ indicator-variant="circle"
61
+ :connected="true"
62
+ :item-count="serviceData.process.length"
63
+ class="service-detail__process"
64
+ >
65
+ <template v-for="(step, i) in serviceData.process" :key="i" #[`item-${i}`]>
66
+ <p class="page-body-normal">{{ step }}</p>
67
+ </template>
68
+ </StepperList>
69
+
70
+ <HeroText
71
+ :tag="subheadingTag"
72
+ axis="horizontal"
73
+ font-size="subheading"
74
+ :text-content="[{ text: idealForHeading, styleClass: 'normal' }]"
75
+ :style-class-passthrough="['mb-20']"
76
+ />
77
+ <ul class="service-detail__ideal-for">
78
+ <li v-for="(item, i) in serviceData.idealFor" :key="i" class="service-detail__ideal-for-item">
79
+ <Icon name="mdi:diamond-stone" class="service-detail__ideal-for-icon" />
80
+ <p class="page-body-normal">{{ item }}</p>
81
+ </li>
82
+ </ul>
83
+
84
+ <HeroText
85
+ :tag="subheadingTag"
86
+ axis="horizontal"
87
+ font-size="subheading"
88
+ :text-content="[{ text: maintenanceHeading, styleClass: 'normal' }]"
89
+ :style-class-passthrough="['mb-20']"
90
+ />
91
+ <p class="page-body-normal">{{ serviceData.maintenance }}</p>
92
+
93
+ <HeroText
94
+ :tag="subheadingTag"
95
+ axis="horizontal"
96
+ font-size="subheading"
97
+ :text-content="[{ text: faqsHeading, styleClass: 'normal' }]"
98
+ :style-class-passthrough="['mb-20']"
99
+ />
100
+ <div class="service-detail__faqs">
101
+ <div v-for="(faq, i) in serviceData.faqs" :key="i" class="service-detail__faq">
102
+ <h3 class="service-detail__faq-question">{{ faq.question }}</h3>
103
+ <p class="page-body-normal">{{ faq.answer }}</p>
104
+ </div>
97
105
  </div>
98
106
  </div>
99
- </div>
100
107
 
101
- <aside class="service-detail__sidebar">
102
- <GlassPanel :style-class-passthrough="['service-detail__booking-card']">
103
- <p class="service-detail__sidebar-label">{{ bookingHeading }}</p>
108
+ <aside class="service-detail__sidebar">
109
+ <GlassPanel :style-class-passthrough="['service-detail__booking-card']">
110
+ <p class="service-detail__sidebar-label">{{ bookingHeading }}</p>
104
111
 
105
- <div class="service-detail__sidebar-row">
106
- <span>{{ priceLabel }}</span>
107
- <span>{{ serviceData.price }}</span>
108
- </div>
109
- <div class="service-detail__sidebar-row">
110
- <span>{{ durationLabel }}</span>
111
- <span>{{ serviceData.duration }}</span>
112
- </div>
113
- <div v-if="location" class="service-detail__sidebar-row">
114
- <span>{{ locationLabel }}</span>
115
- <span>{{ location }}</span>
116
- </div>
112
+ <div class="service-detail__sidebar-row">
113
+ <span>{{ priceLabel }}</span>
114
+ <span>{{ serviceData.price }}</span>
115
+ </div>
116
+ <div class="service-detail__sidebar-row">
117
+ <span>{{ durationLabel }}</span>
118
+ <span>{{ serviceData.duration }}</span>
119
+ </div>
120
+ <div v-if="location" class="service-detail__sidebar-row">
121
+ <span>{{ locationLabel }}</span>
122
+ <span>{{ location }}</span>
123
+ </div>
117
124
 
118
- <slot name="book-cta" :service-data="serviceData"></slot>
125
+ <div v-if="$slots['book-cta']" class="service-detail__sidebar-row book-cta">
126
+ <slot name="book-cta" :service-data="serviceData"></slot>
127
+ </div>
119
128
 
120
- <div class="service-detail__sidebar-note">
121
- <slot name="sidebar-note"></slot>
122
- </div>
123
- </GlassPanel>
124
-
125
- <div v-if="relatedServices.length" class="service-detail__related">
126
- <p class="service-detail__sidebar-label">{{ relatedServicesHeading }}</p>
127
-
128
- <div class="service-detail__related-items">
129
- <div v-for="(related, i) in relatedServices" :key="related.slug" class="service-detail__related-item-slot">
130
- <slot name="related-service" :service="related" :index="i">
131
- <div class="service-detail__related-item">
132
- <NuxtImg
133
- :src="related.image"
134
- :alt="related.title"
135
- loading="lazy"
136
- class="service-detail__related-image"
137
- />
138
- <div class="service-detail__related-details">
139
- <p class="service-detail__related-title">{{ related.title }}</p>
140
- <p class="service-detail__related-price">{{ related.price }}</p>
129
+ <div class="service-detail__sidebar-note">
130
+ <slot name="sidebar-note"></slot>
131
+ </div>
132
+ </GlassPanel>
133
+
134
+ <div v-if="relatedServices.length" class="service-detail__related">
135
+ <p class="service-detail__sidebar-label">{{ relatedServicesHeading }}</p>
136
+
137
+ <div class="service-detail__related-items">
138
+ <div
139
+ v-for="(related, i) in relatedServices"
140
+ :key="related.slug"
141
+ class="service-detail__related-item-slot"
142
+ >
143
+ <slot name="related-service" :service="related" :index="i">
144
+ <div class="service-detail__related-item">
145
+ <NuxtImg
146
+ :src="related.image"
147
+ :alt="related.title"
148
+ loading="lazy"
149
+ class="service-detail__related-image"
150
+ />
151
+ <div class="service-detail__related-details">
152
+ <p class="service-detail__related-title">{{ related.title }}</p>
153
+ <p class="service-detail__related-price">{{ related.price }}</p>
154
+ </div>
141
155
  </div>
142
- </div>
143
- </slot>
156
+ </slot>
157
+ </div>
144
158
  </div>
145
159
  </div>
160
+ </aside>
161
+ </div>
162
+ </PageRow>
163
+
164
+ <PageRow tag="div" :variant="finalCtaVariant" class="service-detail__final-cta-row">
165
+ <div class="service-detail__final-cta">
166
+ <div class="service-detail__final-cta-copy">
167
+ <HeroText
168
+ :tag="subheadingTag"
169
+ axis="horizontal"
170
+ font-size="subheading"
171
+ :text-content="[{ text: finalCtaHeading, styleClass: 'normal' }]"
172
+ :style-class-passthrough="['mbs-0', 'mbe-8']"
173
+ />
174
+ <p class="page-body-normal">{{ finalCtaBody }}</p>
146
175
  </div>
147
- </aside>
148
- </div>
149
-
150
- <div class="service-detail__final-cta">
151
- <div class="service-detail__final-cta-copy">
152
- <HeroText
153
- :tag="subheadingTag"
154
- axis="horizontal"
155
- font-size="subheading"
156
- :text-content="[{ text: finalCtaHeading, styleClass: 'normal' }]"
157
- :style-class-passthrough="['mbs-0', 'mbe-8']"
158
- />
159
- <p class="page-body-normal">{{ finalCtaBody }}</p>
176
+ <slot name="final-cta" :service-data="serviceData"></slot>
160
177
  </div>
161
- <slot name="final-cta" :service-data="serviceData"></slot>
162
- </div>
178
+ </PageRow>
163
179
  </component>
164
180
  </template>
165
181
 
@@ -173,6 +189,10 @@ interface Props {
173
189
  subheadingTag?: "h2" | "h3";
174
190
  serviceData: Service;
175
191
  breadcrumbItems?: BreadcrumbItem[];
192
+ heroImageVariant?: "full" | "popout" | "content" | "inset-content";
193
+ heroContentVariant?: "full" | "popout" | "content" | "inset-content";
194
+ bodyVariant?: "full" | "popout" | "content" | "inset-content";
195
+ finalCtaVariant?: "full" | "popout" | "content" | "inset-content";
176
196
  processHeading?: string;
177
197
  idealForHeading?: string;
178
198
  maintenanceHeading?: string;
@@ -196,6 +216,10 @@ const props = withDefaults(defineProps<Props>(), {
196
216
  headerTag: "h1",
197
217
  subheadingTag: "h2",
198
218
  breadcrumbItems: undefined,
219
+ heroImageVariant: "full",
220
+ heroContentVariant: "content",
221
+ bodyVariant: "content",
222
+ finalCtaVariant: "content",
199
223
  processHeading: "The Process",
200
224
  idealForHeading: "Ideal For",
201
225
  maintenanceHeading: "Aftercare & Maintenance",
@@ -239,49 +263,71 @@ watch(
239
263
  container-name: service-detail;
240
264
 
241
265
  .service-detail__hero {
242
- position: relative;
243
266
  border-radius: var(--service-detail-hero-border-radius, 0.8rem);
244
267
  overflow: hidden;
245
- min-height: var(--service-detail-hero-min-height, 32rem);
268
+ height: var(--service-detail-hero-min-height-mobile, 32rem);
269
+
270
+ @container (width >= 768px) {
271
+ height: var(--service-detail-hero-min-height-tablet, 36rem);
272
+ }
273
+
274
+ @container (width >= 1024px) {
275
+ height: var(--service-detail-hero-min-height-desktop, 42rem);
276
+ }
277
+
278
+ .grid-stack__layer {
279
+ height: 100%;
280
+ overflow: hidden;
281
+
282
+ .service-detail__hero-image-row,
283
+ .service-detail__hero-overlay {
284
+ height: 100%;
285
+ }
286
+ }
246
287
 
247
288
  .service-detail__hero-image {
248
289
  display: block;
249
290
  width: 100%;
250
291
  height: 100%;
251
292
  object-fit: cover;
252
- position: absolute;
253
- inset: 0;
293
+ object-position: var(--service-detail-hero-image-position-small, bottom);
294
+
295
+ @container (width >= 768px) {
296
+ object-fit: contain;
297
+ object-position: var(--service-detail-hero-image-position-medium, center);
298
+ }
254
299
  }
255
300
 
256
301
  .service-detail__hero-overlay {
257
- position: relative;
258
- display: flex;
259
- align-items: flex-end;
260
- min-height: var(--service-detail-hero-min-height, 32rem);
261
- padding: var(--service-detail-hero-padding, 2.4rem);
302
+ display: grid;
303
+ height: 100%;
304
+ align-content: var(--service-detail-hero-content-align, end);
305
+ padding-block: var(--service-detail-hero-padding, 3.2rem);
262
306
  background: var(
263
307
  --service-detail-hero-scrim,
264
308
  linear-gradient(0deg, rgb(0 0 0 / 70%) 0%, rgb(0 0 0 / 10%) 60%, transparent 100%)
265
309
  );
266
310
 
267
- .service-detail__breadcrumb {
268
- --breadcrumb-colour: var(--service-detail-hero-text-colour, white);
269
- margin-block-end: var(--service-detail-breadcrumb-margin-block-end, 0.8rem);
270
- }
311
+ .service-detail__hero-content {
312
+ .service-detail__breadcrumb {
313
+ --breadcrumb-colour: var(--service-detail-hero-text-colour, white);
314
+ margin-block-end: var(--service-detail-breadcrumb-margin-block-end, 0.8rem);
315
+ }
271
316
 
272
- .eyebrow-text,
273
- .hero-text {
274
- color: var(--service-detail-hero-text-colour, white);
275
- }
317
+ .eyebrow-text,
318
+ .hero-text {
319
+ color: var(--service-detail-hero-text-colour, white);
320
+ }
276
321
 
277
- .service-detail__hero-pills {
278
- display: flex;
279
- flex-wrap: wrap;
280
- gap: var(--service-detail-hero-pills-gap, 0.8rem);
322
+ .service-detail__hero-pills {
323
+ display: flex;
324
+ flex-wrap: wrap;
325
+ gap: var(--service-detail-hero-pills-gap, 0.8rem);
281
326
 
282
- --theme-pill-bg: var(--service-detail-hero-pill-bg, transparent);
283
- --theme-pill-color: var(--service-detail-hero-text-colour, white);
284
- --theme-pill-border-color: var(--service-detail-hero-pill-border-colour, currentColor);
327
+ --theme-pill-bg: var(--service-detail-hero-pill-bg, transparent);
328
+ --theme-pill-color: var(--service-detail-hero-text-colour, white);
329
+ --theme-pill-border-color: var(--service-detail-hero-pill-border-colour, currentColor);
330
+ }
285
331
  }
286
332
  }
287
333
  }
@@ -339,6 +385,7 @@ watch(
339
385
  width: var(--service-detail-ideal-for-icon-size, 1.6rem);
340
386
  height: var(--service-detail-ideal-for-icon-size, 1.6rem);
341
387
  color: var(--service-detail-ideal-for-icon-colour, var(--colour-text-accent));
388
+ transform: var(--service-detail-ideal-for-icon-transform, translateY(0.4rem));
342
389
  }
343
390
 
344
391
  p {
@@ -396,6 +443,12 @@ watch(
396
443
  padding-block: var(--service-detail-sidebar-row-padding-block, 1rem);
397
444
  border-block-start: 1px solid var(--service-detail-sidebar-row-divider-colour, currentColor);
398
445
 
446
+ &.book-cta {
447
+ padding-block: var(--service-detail-sidebar-row-book-cta-padding-block, 2rem);
448
+ border-block-start: none;
449
+ justify-content: var(--service-detail-sidebar-row-book-cta-justify-content, end);
450
+ }
451
+
399
452
  span:first-child {
400
453
  text-transform: uppercase;
401
454
  font-size: var(--service-detail-sidebar-row-label-font-size, 1.2rem);
@@ -423,55 +476,58 @@ watch(
423
476
  padding: var(--service-detail-related-item-padding, 1rem);
424
477
  border: 1px solid var(--service-detail-related-item-border-colour, var(--slate-06));
425
478
  border-radius: var(--service-detail-related-item-border-radius, 0.4rem);
426
- }
427
-
428
- .service-detail__related-item {
429
- display: grid;
430
- grid-template-columns: auto 1fr;
431
- gap: var(--service-detail-related-item-gap, 1.2rem);
432
- align-items: center;
433
-
434
- .service-detail__related-image {
435
- width: var(--service-detail-related-image-size, 5.6rem);
436
- height: var(--service-detail-related-image-size, 5.6rem);
437
- border-radius: var(--service-detail-related-image-border-radius, 0.4rem);
438
- object-fit: cover;
439
- display: block;
440
- }
441
479
 
442
- .service-detail__related-title,
443
- .service-detail__related-price {
444
- margin: 0;
445
- }
446
-
447
- .service-detail__related-price {
448
- font-size: var(--service-detail-related-price-font-size, 1.2rem);
449
- opacity: var(--service-detail-muted-opacity, 0.7);
480
+ .service-detail__related-item {
481
+ display: grid;
482
+ grid-template-columns: auto 1fr;
483
+ gap: var(--service-detail-related-item-gap, 1.2rem);
484
+ align-items: center;
485
+
486
+ .service-detail__related-image {
487
+ width: var(--service-detail-related-image-size, 5.6rem);
488
+ height: var(--service-detail-related-image-size, 5.6rem);
489
+ border-radius: var(--service-detail-related-image-border-radius, 0.4rem);
490
+ object-fit: cover;
491
+ display: block;
492
+ }
493
+
494
+ .service-detail__related-title,
495
+ .service-detail__related-price {
496
+ margin: 0;
497
+ }
498
+
499
+ .service-detail__related-price {
500
+ margin-block-start: var(--service-detail-related-price-margin-block-start, 0.6rem);
501
+ font-size: var(--service-detail-related-price-font-size, 1.2rem);
502
+ opacity: var(--service-detail-muted-opacity, 0.7);
503
+ }
450
504
  }
451
505
  }
452
506
  }
453
507
  }
454
508
  }
455
509
 
456
- .service-detail__final-cta {
457
- display: flex;
458
- flex-direction: column;
459
- gap: var(--service-detail-final-cta-gap, 1.6rem);
460
- align-items: flex-start;
461
- justify-content: space-between;
462
- margin-block-start: var(--service-detail-final-cta-margin-block-start, 4rem);
463
- padding-block-start: var(--service-detail-final-cta-padding-block-start, 3.2rem);
464
- border-block-start: 1px solid var(--service-detail-final-cta-divider-colour, currentColor);
465
-
466
- @container (width >= 700px) {
467
- flex-direction: row;
468
- align-items: center;
469
- }
510
+ .service-detail__final-cta-row {
511
+ .service-detail__final-cta {
512
+ display: flex;
513
+ flex-direction: column;
514
+ gap: var(--service-detail-final-cta-gap, 1.6rem);
515
+ align-items: flex-start;
516
+ justify-content: space-between;
517
+ margin-block-start: var(--service-detail-final-cta-margin-block-start, 3.2rem);
518
+ padding-block: var(--service-detail-final-cta-padding-block, 3.2rem 3.2rem);
519
+ border-block-start: 1px solid var(--service-detail-final-cta-divider-colour, currentColor);
470
520
 
471
- .service-detail__final-cta-copy {
472
- p {
473
- margin: 0;
474
- opacity: var(--service-detail-muted-opacity, 0.7);
521
+ @container (width >= 700px) {
522
+ flex-direction: row;
523
+ align-items: center;
524
+ }
525
+
526
+ .service-detail__final-cta-copy {
527
+ p {
528
+ margin: 0;
529
+ opacity: var(--service-detail-muted-opacity, 0.7);
530
+ }
475
531
  }
476
532
  }
477
533
  }
@@ -27,6 +27,26 @@ const meta: Meta<typeof ServiceDetail> = {
27
27
  description:
28
28
  "Items for the Breadcrumb atom. Defaults to a plain (non-linked) [category, title] trail built from serviceData when omitted",
29
29
  },
30
+ heroImageVariant: {
31
+ control: { type: "select" },
32
+ options: ["full", "popout", "content", "inset-content"],
33
+ description: "PageRow variant applied to the hero image layer",
34
+ },
35
+ heroContentVariant: {
36
+ control: { type: "select" },
37
+ options: ["full", "popout", "content", "inset-content"],
38
+ description: "PageRow variant applied to the hero content layer (breadcrumb/title/pills over the image)",
39
+ },
40
+ bodyVariant: {
41
+ control: { type: "select" },
42
+ options: ["full", "popout", "content", "inset-content"],
43
+ description: "PageRow variant applied to the two-column body (main content + sidebar)",
44
+ },
45
+ finalCtaVariant: {
46
+ control: { type: "select" },
47
+ options: ["full", "popout", "content", "inset-content"],
48
+ description: "PageRow variant applied to the closing full-width CTA banner",
49
+ },
30
50
  processHeading: { control: "text", description: "Heading text for the process section" },
31
51
  idealForHeading: { control: "text", description: "Heading text for the ideal-for section" },
32
52
  maintenanceHeading: { control: "text", description: "Heading text for the maintenance/aftercare section" },
@@ -46,6 +66,10 @@ const meta: Meta<typeof ServiceDetail> = {
46
66
  tag: "article",
47
67
  headerTag: "h1",
48
68
  subheadingTag: "h2",
69
+ heroImageVariant: "full",
70
+ heroContentVariant: "content",
71
+ bodyVariant: "content",
72
+ finalCtaVariant: "content",
49
73
  styleClassPassthrough: [],
50
74
  },
51
75
  parameters: {
@@ -119,6 +119,50 @@ describe("ServiceDetail", () => {
119
119
  expect(wrapper.text()).not.toContain(mockService.category);
120
120
  });
121
121
 
122
+ // ─── Layout ─────────────────────────────────────────────────────────────
123
+
124
+ it("wraps the hero in a GridStack and the body in a PageRow", async () => {
125
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
126
+ expect(wrapper.find(".service-detail__hero.grid-stack").exists()).toBe(true);
127
+ expect(wrapper.find(".service-detail__body-row.page-row").exists()).toBe(true);
128
+ });
129
+
130
+ it("defaults heroImageVariant to full, heroContentVariant and bodyVariant to content", async () => {
131
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
132
+ expect(wrapper.find(".service-detail__hero-image-row").classes()).toContain("full");
133
+ expect(wrapper.find(".service-detail__hero-overlay").classes()).toContain("content");
134
+ expect(wrapper.find(".service-detail__body-row").classes()).toContain("content");
135
+ });
136
+
137
+ it("applies the heroImageVariant prop to the hero image PageRow", async () => {
138
+ const wrapper = await mountSuspended(ServiceDetail, {
139
+ props: { serviceData: mockService, heroImageVariant: "popout" },
140
+ });
141
+ expect(wrapper.find(".service-detail__hero-image-row").classes()).toContain("popout");
142
+ });
143
+
144
+ it("applies the heroContentVariant prop to the hero content PageRow", async () => {
145
+ const wrapper = await mountSuspended(ServiceDetail, {
146
+ props: { serviceData: mockService, heroContentVariant: "inset-content" },
147
+ });
148
+ expect(wrapper.find(".service-detail__hero-overlay").classes()).toContain("inset-content");
149
+ });
150
+
151
+ it("applies the bodyVariant prop to the body PageRow", async () => {
152
+ const wrapper = await mountSuspended(ServiceDetail, {
153
+ props: { serviceData: mockService, bodyVariant: "popout" },
154
+ });
155
+ expect(wrapper.find(".service-detail__body-row").classes()).toContain("popout");
156
+ });
157
+
158
+ it("defaults finalCtaVariant to content and applies the prop to the final CTA PageRow", async () => {
159
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
160
+ expect(wrapper.find(".service-detail__final-cta-row").classes()).toContain("content");
161
+
162
+ await wrapper.setProps({ finalCtaVariant: "full" });
163
+ expect(wrapper.find(".service-detail__final-cta-row").classes()).toContain("full");
164
+ });
165
+
122
166
  // ─── Service data ───────────────────────────────────────────────────────
123
167
 
124
168
  it("renders service price and duration in the hero pills", async () => {
@@ -2,97 +2,107 @@
2
2
 
3
3
  exports[`ServiceDetail > renders correct HTML structure 1`] = `
4
4
  "<div class="service-detail">
5
- <div class="service-detail__hero"><img data-nuxt-img="" srcset="/_ipx/_/images/balayage.jpg 1x, /_ipx/_/images/balayage.jpg 2x" alt="Balayage" loading="eager" fetchpriority="high" class="service-detail__hero-image" src="/_ipx/_/images/balayage.jpg">
6
- <div class="service-detail__hero-overlay">
7
- <div class="service-detail__hero-content">
8
- <nav class="breadcrumb service-detail__breadcrumb" aria-label="Breadcrumb">
9
- <ol class="breadcrumb__list">
10
- <li class="breadcrumb__item"><span class="breadcrumb__label">Colour</span><span class="breadcrumb__separator" aria-hidden="true">/</span></li>
11
- <li class="breadcrumb__item"><span class="breadcrumb__label" aria-current="page">Balayage</span>
12
- <!--v-if-->
13
- </li>
14
- </ol>
15
- </nav>
16
- <div class="eyebrow-text large">Freehand colour artistry</div>
17
- <h1 id="v-0-0" class="hero-text mb-20 display axis-horizontal">
18
- <!--v-if--><span class="text-block-0 normal">Balayage</span>
19
- </h1>
20
- <div class="service-detail__hero-pills"><span class="display-pill md neutral"><span class="pill-label">2.5 - 3.5 hours</span></span><span class="display-pill md neutral"><span class="pill-label">From £95</span></span></div>
5
+ <div class="grid-stack service-detail__hero">
6
+ <div class="grid-stack__layer">
7
+ <div class="page-row full service-detail__hero-image-row"><img data-nuxt-img="" srcset="/_ipx/_/images/balayage.jpg 1x, /_ipx/_/images/balayage.jpg 2x" alt="Balayage" loading="eager" fetchpriority="high" class="service-detail__hero-image" src="/_ipx/_/images/balayage.jpg"></div>
8
+ </div>
9
+ <div class="grid-stack__layer">
10
+ <div class="page-row content service-detail__hero-overlay">
11
+ <div class="service-detail__hero-content">
12
+ <nav class="breadcrumb service-detail__breadcrumb" aria-label="Breadcrumb">
13
+ <ol class="breadcrumb__list">
14
+ <li class="breadcrumb__item"><span class="breadcrumb__label">Colour</span><span class="breadcrumb__separator" aria-hidden="true">/</span></li>
15
+ <li class="breadcrumb__item"><span class="breadcrumb__label" aria-current="page">Balayage</span>
16
+ <!--v-if-->
17
+ </li>
18
+ </ol>
19
+ </nav>
20
+ <div class="eyebrow-text large">Freehand colour artistry</div>
21
+ <h1 id="v-0-0" class="hero-text mb-20 display axis-horizontal">
22
+ <!--v-if--><span class="text-block-0 normal">Balayage</span>
23
+ </h1>
24
+ <div class="service-detail__hero-pills"><span class="display-pill md neutral"><span class="pill-label">2.5 - 3.5 hours</span></span><span class="display-pill md neutral"><span class="pill-label">From £95</span></span></div>
25
+ </div>
21
26
  </div>
22
27
  </div>
23
28
  </div>
24
- <div class="service-detail__body">
25
- <div class="service-detail__main">
26
- <p class="page-body-normal">Balayage is a freehand hair colouring technique that creates a soft gradient.</p>
27
- <h2 class="hero-text mb-20 subheading axis-horizontal">
28
- <!--v-if--><span class="text-block-0 normal">What is Balayage?</span>
29
- </h2>
30
- <p class="page-body-normal">The word balayage comes from the French for 'to sweep'.</p>
31
- <h2 class="hero-text mb-20 subheading axis-horizontal">
32
- <!--v-if--><span class="text-block-0 normal">The Process</span>
33
- </h2>
34
- <ol class="stepper-list has-connectors service-detail__process">
35
- <li class="indicator-top indicator-circle" style="--_connector-top: 0px; --_connector-height: 0px;">
36
- <div class="stepper-list__indicator-counter"></div>
37
- <div>
38
- <p class="page-body-normal">Consultation</p>
39
- </div>
40
- </li>
41
- <li class="indicator-top indicator-circle" style="--_connector-top: 0px; --_connector-height: 0px;">
42
- <div class="stepper-list__indicator-counter"></div>
43
- <div>
44
- <p class="page-body-normal">Sectioning</p>
29
+ <div class="page-row content service-detail__body-row">
30
+ <div class="service-detail__body">
31
+ <div class="service-detail__main">
32
+ <p class="page-body-normal">Balayage is a freehand hair colouring technique that creates a soft gradient.</p>
33
+ <h2 class="hero-text mb-20 subheading axis-horizontal">
34
+ <!--v-if--><span class="text-block-0 normal">What is Balayage?</span>
35
+ </h2>
36
+ <p class="page-body-normal">The word balayage comes from the French for 'to sweep'.</p>
37
+ <h2 class="hero-text mb-20 subheading axis-horizontal">
38
+ <!--v-if--><span class="text-block-0 normal">The Process</span>
39
+ </h2>
40
+ <ol class="stepper-list has-connectors service-detail__process">
41
+ <li class="indicator-top indicator-circle" style="--_connector-top: 0px; --_connector-height: 0px;">
42
+ <div class="stepper-list__indicator-counter"></div>
43
+ <div>
44
+ <p class="page-body-normal">Consultation</p>
45
+ </div>
46
+ </li>
47
+ <li class="indicator-top indicator-circle" style="--_connector-top: 0px; --_connector-height: 0px;">
48
+ <div class="stepper-list__indicator-counter"></div>
49
+ <div>
50
+ <p class="page-body-normal">Sectioning</p>
51
+ </div>
52
+ </li>
53
+ <li class="indicator-top indicator-circle">
54
+ <div class="stepper-list__indicator-counter"></div>
55
+ <div>
56
+ <p class="page-body-normal">Painting</p>
57
+ </div>
58
+ </li>
59
+ </ol>
60
+ <h2 class="hero-text mb-20 subheading axis-horizontal">
61
+ <!--v-if--><span class="text-block-0 normal">Ideal For</span>
62
+ </h2>
63
+ <ul class="service-detail__ideal-for">
64
+ <li class="service-detail__ideal-for-item"><span class="iconify i-mdi:diamond-stone service-detail__ideal-for-icon" aria-hidden="true"></span>
65
+ <p class="page-body-normal">First-time colour clients</p>
66
+ </li>
67
+ <li class="service-detail__ideal-for-item"><span class="iconify i-mdi:diamond-stone service-detail__ideal-for-icon" aria-hidden="true"></span>
68
+ <p class="page-body-normal">Low-maintenance colour</p>
69
+ </li>
70
+ </ul>
71
+ <h2 class="hero-text mb-20 subheading axis-horizontal">
72
+ <!--v-if--><span class="text-block-0 normal">Aftercare &amp; Maintenance</span>
73
+ </h2>
74
+ <p class="page-body-normal">Balayage is low maintenance since regrowth is soft and natural.</p>
75
+ <h2 class="hero-text mb-20 subheading axis-horizontal">
76
+ <!--v-if--><span class="text-block-0 normal">Frequently Asked Questions</span>
77
+ </h2>
78
+ <div class="service-detail__faqs">
79
+ <div class="service-detail__faq">
80
+ <h3 class="service-detail__faq-question">Will it damage my hair?</h3>
81
+ <p class="page-body-normal">Balayage is gentler than full-head bleaching.</p>
45
82
  </div>
46
- </li>
47
- <li class="indicator-top indicator-circle">
48
- <div class="stepper-list__indicator-counter"></div>
49
- <div>
50
- <p class="page-body-normal">Painting</p>
51
- </div>
52
- </li>
53
- </ol>
54
- <h2 class="hero-text mb-20 subheading axis-horizontal">
55
- <!--v-if--><span class="text-block-0 normal">Ideal For</span>
56
- </h2>
57
- <ul class="service-detail__ideal-for">
58
- <li class="service-detail__ideal-for-item"><span class="iconify i-mdi:diamond-stone service-detail__ideal-for-icon" aria-hidden="true"></span>
59
- <p class="page-body-normal">First-time colour clients</p>
60
- </li>
61
- <li class="service-detail__ideal-for-item"><span class="iconify i-mdi:diamond-stone service-detail__ideal-for-icon" aria-hidden="true"></span>
62
- <p class="page-body-normal">Low-maintenance colour</p>
63
- </li>
64
- </ul>
65
- <h2 class="hero-text mb-20 subheading axis-horizontal">
66
- <!--v-if--><span class="text-block-0 normal">Aftercare &amp; Maintenance</span>
67
- </h2>
68
- <p class="page-body-normal">Balayage is low maintenance since regrowth is soft and natural.</p>
69
- <h2 class="hero-text mb-20 subheading axis-horizontal">
70
- <!--v-if--><span class="text-block-0 normal">Frequently Asked Questions</span>
71
- </h2>
72
- <div class="service-detail__faqs">
73
- <div class="service-detail__faq">
74
- <h3 class="service-detail__faq-question">Will it damage my hair?</h3>
75
- <p class="page-body-normal">Balayage is gentler than full-head bleaching.</p>
76
83
  </div>
77
84
  </div>
78
- </div>
79
- <aside class="service-detail__sidebar">
80
- <div class="glass-panel service-detail__booking-card">
81
- <p class="service-detail__sidebar-label">Book This Service</p>
82
- <div class="service-detail__sidebar-row"><span>Price</span><span>£95</span></div>
83
- <div class="service-detail__sidebar-row"><span>Duration</span><span>2.5 - 3.5 hours</span></div>
85
+ <aside class="service-detail__sidebar">
86
+ <div class="glass-panel service-detail__booking-card">
87
+ <p class="service-detail__sidebar-label">Book This Service</p>
88
+ <div class="service-detail__sidebar-row"><span>Price</span><span>£95</span></div>
89
+ <div class="service-detail__sidebar-row"><span>Duration</span><span>2.5 - 3.5 hours</span></div>
90
+ <!--v-if-->
91
+ <!--v-if-->
92
+ <div class="service-detail__sidebar-note"></div>
93
+ </div>
84
94
  <!--v-if-->
85
- <div class="service-detail__sidebar-note"></div>
86
- </div>
87
- <!--v-if-->
88
- </aside>
95
+ </aside>
96
+ </div>
89
97
  </div>
90
- <div class="service-detail__final-cta">
91
- <div class="service-detail__final-cta-copy">
92
- <h2 class="hero-text mbs-0 mbe-8 subheading axis-horizontal">
93
- <!--v-if--><span class="text-block-0 normal">Ready to book your appointment?</span>
94
- </h2>
95
- <p class="page-body-normal">Get in touch to book your appointment.</p>
98
+ <div class="page-row content service-detail__final-cta-row">
99
+ <div class="service-detail__final-cta">
100
+ <div class="service-detail__final-cta-copy">
101
+ <h2 class="hero-text mbs-0 mbe-8 subheading axis-horizontal">
102
+ <!--v-if--><span class="text-block-0 normal">Ready to book your appointment?</span>
103
+ </h2>
104
+ <p class="page-body-normal">Get in touch to book your appointment.</p>
105
+ </div>
96
106
  </div>
97
107
  </div>
98
108
  </div>"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.3.8",
4
+ "version": "9.3.9",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",