srcdev-nuxt-components 9.3.11 → 9.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.claude/skills/component-aria-landmark.md +3 -3
  2. package/.claude/skills/components/auto-grid.md +1 -1
  3. package/.claude/skills/components/page-hero-highlights.md +1 -1
  4. package/.claude/skills/components/service-detail.md +13 -14
  5. package/.claude/skills/components/service-summary-grid.md +141 -0
  6. package/.claude/skills/components/service-summary.md +165 -0
  7. package/.claude/skills/icon-sets.md +1 -1
  8. package/.claude/skills/index.md +2 -2
  9. package/.vscode/srcdev-component-service-summary.code-snippets +67 -0
  10. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +7 -8
  11. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +0 -1
  12. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +2 -2
  13. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +116 -0
  14. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +183 -0
  15. package/app/components/03.organisms/services/{services-section/stories/ServicesSection.stories.ts → service-summary/stories/ServiceSummary.stories.ts} +78 -148
  16. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +210 -0
  17. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +17 -0
  18. package/app/components/03.organisms/services/services-grids/{ServicesSectionGrid.vue → ServiceSummaryGrid.vue} +8 -10
  19. package/app/components/03.organisms/services/services-grids/stories/{ServicesSectionGrid.stories.ts → ServiceSummaryGrid.stories.ts} +21 -21
  20. package/app/components/03.organisms/services/services-grids/tests/{ServicesSectionGrid.spec.ts → ServiceSummaryGrid.spec.ts} +28 -28
  21. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +45 -0
  22. package/package.json +1 -1
  23. package/.claude/skills/components/services-section-grid.md +0 -130
  24. package/.claude/skills/components/services-section.md +0 -211
  25. package/.vscode/srcdev-component-services-section.code-snippets +0 -84
  26. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +0 -87
  27. package/app/components/03.organisms/services/services-section/CONSUMER-STYLING.md +0 -149
  28. package/app/components/03.organisms/services/services-section/ServicesSection.vue +0 -345
  29. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +0 -297
  30. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +0 -87
@@ -0,0 +1,116 @@
1
+ # ServiceSummary — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ All `--service-summary-*` tokens are the stable override surface. Set them globally in a
6
+ theme file, scoped to a page wrapper, or per-instance via `styleClassPassthrough`.
7
+
8
+ ### Layout & sizing
9
+
10
+ | Token | Default | Controls |
11
+ |---|---|---|
12
+ | `--service-summary-grid-gap` | `2rem` | Gap between image and content columns below the 768px container breakpoint |
13
+ | `--service-summary-grid-gap-desktop` | `3rem` | Gap between columns at the 768px container breakpoint and above |
14
+ | `--service-summary-height-mobile` | `auto` | Height of the whole `.service-summary` row below the 768px container breakpoint. `auto` (the default) lets the row size to its content — the image sizes itself via `--service-summary-image-aspect-ratio` and the text column sizes to its own content. Set a fixed length to force both columns to share a common row height instead (the image then crops via `object-fit: cover` to fill it, and `alignment` positions the text within the leftover space) |
15
+ | `--service-summary-height-tablet` | `auto` (falls back to `--service-summary-height-mobile`) | Row height from the 768px container breakpoint |
16
+ | `--service-summary-height-desktop` | `auto` (falls back to `--service-summary-height-tablet`, then `-mobile`) | Row height from the 1024px container breakpoint |
17
+ | `--service-summary-image-aspect-ratio` | `1 / 1` | Aspect ratio of `.service-summary__image-wrapper` when its height isn't otherwise forced by a `--service-summary-height-*` override (i.e. the default, row-height-auto case) — has no visible effect once a row height override makes both dimensions definite |
18
+ | `--service-summary-image-padding-block-mobile` | `0` | Padding-block (top/bottom) inset around the image, inside `.service-summary__image-wrapper`, below the 768px container breakpoint — creates a visible frame/border effect since the image itself sizes to the wrapper's content box, not the padding box |
19
+ | `--service-summary-image-padding-block-tablet` | `0` (falls back to `-mobile`) | Padding-block from the 768px container breakpoint |
20
+ | `--service-summary-image-padding-block-desktop` | `0` (falls back to `-tablet`, then `-mobile`) | Padding-block from the 1024px container breakpoint |
21
+ | `--service-summary-image-padding-inline-mobile` | `0` | Padding-inline (left/right) inset around the image below the 768px container breakpoint |
22
+ | `--service-summary-image-padding-inline-tablet` | `0` (falls back to `-mobile`) | Padding-inline from the 768px container breakpoint |
23
+ | `--service-summary-image-padding-inline-desktop` | `0` (falls back to `-tablet`, then `-mobile`) | Padding-inline from the 1024px container breakpoint |
24
+ | `--service-summary-image-border-radius` | `0.8rem` | Corner rounding on the service image |
25
+ | `--service-summary-pills-gap` | `0.8rem` | Gap between the duration and price pills |
26
+ | `--service-summary-pills-margin-block-end` | `2rem` | Space below the pill row |
27
+
28
+ All breakpoints above (row height, image padding, and the two-column grid switch) are
29
+ **container queries** against `.service-summary`'s own inline size (`container-name:
30
+ service-summary`), not the viewport — this keeps the layout responsive to the space
31
+ `ServiceSummary` actually has, which matters when it sits next to a persistent side nav that a
32
+ viewport-based `@media` query can't see.
33
+
34
+ Below the 768px breakpoint the grid is a single stacked column (image above text), so a
35
+ `--service-summary-height-mobile` override sets a height for the *whole stack*, not just the
36
+ image — usually left `auto` on mobile and only set from `--service-summary-height-tablet` up,
37
+ where the layout is genuinely two columns side by side.
38
+
39
+ ### Pill colours
40
+
41
+ The duration/price pills are `DisplayPill` instances — `ServiceSummary` maps its own tokens onto
42
+ `DisplayPill`'s `--theme-pill-*` custom properties scoped to `.service-summary__pills`:
43
+
44
+ | Token | Default | Controls |
45
+ |---|---|---|
46
+ | `--service-summary-pill-bg` | `transparent` | Background of the price/duration pills |
47
+ | `--service-summary-pill-colour` | `currentColor` | Text colour of the pills |
48
+ | `--service-summary-pill-border-colour` | `currentColor` | Border colour of the pills |
49
+
50
+ For anything not covered by these (e.g. pill size, font weight), target `--theme-pill-*` directly
51
+ under `.service-summary__pills` — see [display-pill.md](../../../../../.claude/skills/components/display-pill.md).
52
+
53
+ ---
54
+
55
+ ## Global theming — recommended approach
56
+
57
+ Create `assets/styles/setup/07.components/service-summary.css` in the consuming app and
58
+ set tokens on `:root`. This applies to every `ServiceSummary` across the site.
59
+
60
+ ```css
61
+ /* assets/styles/setup/07.components/service-summary.css */
62
+ :root {
63
+ --service-summary-grid-gap: 2.4rem;
64
+ --service-summary-grid-gap-desktop: 4rem;
65
+ --service-summary-image-border-radius: 1.2rem;
66
+ }
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Page-scoped overrides
72
+
73
+ Override tokens for a specific section by scoping them under the page or layout wrapper.
74
+ No `:deep()` is required (component styles are unscoped).
75
+
76
+ ```css
77
+ /* In the consuming page's unscoped <style> block */
78
+ .our-services-page {
79
+ .service-summary {
80
+ --service-summary-pill-border-colour: var(--brand-accent);
81
+ }
82
+ }
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Per-instance overrides via styleClassPassthrough
88
+
89
+ Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct
90
+ visual style, pass a modifier class:
91
+
92
+ ```vue
93
+ <ServiceSummary :style-class-passthrough="['highlight-service']" :service-data="service" />
94
+ ```
95
+
96
+ ```css
97
+ .service-summary.highlight-service {
98
+ --service-summary-image-border-radius: 2rem;
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Notes
105
+
106
+ - `--service-summary-grid-gap-desktop` only applies at `768px` and above — the breakpoint
107
+ itself is not a token, since changing it would affect the grid's `minmax()` column sizing
108
+ too, not just spacing. The same 768px/1024px breakpoints are shared with the row-height
109
+ tokens above.
110
+ - The row-height tokens live on `.service-summary` itself (the whole row), not on
111
+ `.service-summary__image-wrapper` — `.service-summary__grid` and
112
+ `.service-summary__image-wrapper` both inherit it via `height: 100%`, so overriding the row
113
+ height also gives `alignment` real leftover space to position the text column within.
114
+ - `ServiceSummary` is summary-only — it has no "full mode". For a complete single-service page
115
+ (process, ideal-for list, FAQs, booking CTA), use
116
+ [ServiceDetail](../../../../../.claude/skills/components/service-detail.md) instead.
@@ -0,0 +1,183 @@
1
+ <template>
2
+ <component :is="tag" class="service-summary" :class="[elementClasses]" :aria-labelledby="ariaLabelledby">
3
+ <div class="service-summary__grid" :class="{ 'service-summary__grid--reverse': reverse }">
4
+ <div class="service-summary__image-wrapper">
5
+ <NuxtImg
6
+ :src="serviceData.image"
7
+ :alt="serviceData.title"
8
+ :loading="imageLoading"
9
+ :fetchpriority="imageFetchPriority"
10
+ class="service-summary__image"
11
+ />
12
+ </div>
13
+ <div class="service-summary__info-wrapper" :class="infoWrapperClasses">
14
+ <EyebrowText font-size="large" :text-content="serviceData.subtitle" />
15
+ <HeroText
16
+ :id="headingId"
17
+ :tag="headerTag"
18
+ font-size="title"
19
+ :text-content="[{ text: serviceData.title, styleClass: 'normal' }]"
20
+ :style-class-passthrough="['mb-20']"
21
+ />
22
+
23
+ <div class="service-summary__pills">
24
+ <DisplayPill :label="serviceData.duration" size="md" variant="neutral" />
25
+ <DisplayPill :label="`From ${serviceData.price}`" size="md" variant="neutral" />
26
+ </div>
27
+
28
+ <p class="page-body-normal">{{ serviceData.whatIsIt }}</p>
29
+
30
+ <slot name="summary-link" :service-data="serviceData"></slot>
31
+ </div>
32
+ </div>
33
+ </component>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ import type { Service } from "~/types/types.services";
38
+
39
+ interface Props {
40
+ tag?: "div" | "section" | "article" | "main";
41
+ headerTag?: "h1" | "h2" | "h3";
42
+ index?: number;
43
+ serviceData: Service;
44
+ alignment?: "start" | "center" | "end";
45
+ reverse?: boolean;
46
+ styleClassPassthrough?: string | string[];
47
+ }
48
+ const props = withDefaults(defineProps<Props>(), {
49
+ tag: "div",
50
+ headerTag: "h2",
51
+ index: 0,
52
+ alignment: "center",
53
+ reverse: false,
54
+ styleClassPassthrough: () => [],
55
+ });
56
+
57
+ const { headingId, ariaLabelledby } = useAriaLabelledById(() => props.tag);
58
+
59
+ const infoWrapperClasses = computed(() => ({
60
+ [`service-summary__info-wrapper--align-${props.alignment}`]: true,
61
+ }));
62
+
63
+ // Computed to return loading="lazy" if index is greater than 1, so that the first two summaries prioritise image loading, but if there are more than 2 summaries, the rest will lazy load their images to improve performance.
64
+ const imageLoading = computed(() => (props.index !== undefined && props.index > 1 ? "lazy" : "eager"));
65
+
66
+ // Only the first image (LCP candidate) gets fetchpriority="high" to reduce resource load delay.
67
+ const imageFetchPriority = computed(() => (props.index === 0 ? "high" : "auto"));
68
+
69
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
70
+
71
+ watch(
72
+ () => props.styleClassPassthrough,
73
+ () => {
74
+ resetElementClasses(props.styleClassPassthrough);
75
+ }
76
+ );
77
+ </script>
78
+
79
+ <style lang="css">
80
+ @layer components {
81
+ .service-summary {
82
+ container-type: inline-size;
83
+ container-name: service-summary;
84
+ height: var(--service-summary-height-mobile, auto);
85
+
86
+ @container service-summary (width >= 768px) {
87
+ height: var(--service-summary-height-tablet, var(--service-summary-height-mobile, auto));
88
+ }
89
+
90
+ @container service-summary (width >= 1024px) {
91
+ height: var(
92
+ --service-summary-height-desktop,
93
+ var(--service-summary-height-tablet, var(--service-summary-height-mobile, auto))
94
+ );
95
+ }
96
+
97
+ .service-summary__grid {
98
+ --_grid-gap: var(--service-summary-grid-gap, 2rem);
99
+
100
+ display: grid;
101
+ grid-template-columns: 1fr;
102
+ gap: var(--_grid-gap);
103
+ height: 100%;
104
+
105
+ @container service-summary (width >= 768px) {
106
+ --_grid-gap: var(--service-summary-grid-gap-desktop, 3rem);
107
+
108
+ grid-template-columns: repeat(auto-fit, minmax(246px, 1fr));
109
+
110
+ &.service-summary__grid--reverse {
111
+ .service-summary__image-wrapper {
112
+ /* The order: 2 on .service-summary__image-wrapper pushes it after the content div, since by default both children have order: 0 and source order wins — so the content div naturally sits first. */
113
+ order: 2;
114
+ }
115
+ }
116
+ }
117
+
118
+ .service-summary__info-wrapper {
119
+ display: grid;
120
+
121
+ &.service-summary__info-wrapper--align-start {
122
+ align-content: start;
123
+ }
124
+ &.service-summary__info-wrapper--align-center {
125
+ align-content: center;
126
+ }
127
+ &.service-summary__info-wrapper--align-end {
128
+ align-content: end;
129
+ }
130
+ }
131
+
132
+ .service-summary__image-wrapper {
133
+ height: 100%;
134
+ aspect-ratio: var(--service-summary-image-aspect-ratio, 1 / 1);
135
+ padding-block: var(--service-summary-image-padding-block-mobile, 0);
136
+ padding-inline: var(--service-summary-image-padding-inline-mobile, 0);
137
+ border-radius: var(--service-summary-image-border-radius, 0.8rem);
138
+ overflow: hidden;
139
+
140
+ @container service-summary (width >= 768px) {
141
+ padding-block: var(
142
+ --service-summary-image-padding-block-tablet,
143
+ var(--service-summary-image-padding-block-mobile, 0)
144
+ );
145
+ padding-inline: var(
146
+ --service-summary-image-padding-inline-tablet,
147
+ var(--service-summary-image-padding-inline-mobile, 0)
148
+ );
149
+ }
150
+
151
+ @container service-summary (width >= 1024px) {
152
+ padding-block: var(
153
+ --service-summary-image-padding-block-desktop,
154
+ var(--service-summary-image-padding-block-tablet, var(--service-summary-image-padding-block-mobile, 0))
155
+ );
156
+ padding-inline: var(
157
+ --service-summary-image-padding-inline-desktop,
158
+ var(--service-summary-image-padding-inline-tablet, var(--service-summary-image-padding-inline-mobile, 0))
159
+ );
160
+ }
161
+
162
+ .service-summary__image {
163
+ display: block;
164
+ width: 100%;
165
+ height: 100%;
166
+ object-fit: cover;
167
+ }
168
+ }
169
+
170
+ .service-summary__pills {
171
+ display: flex;
172
+ flex-wrap: wrap;
173
+ gap: var(--service-summary-pills-gap, 0.8rem);
174
+ margin-block-end: var(--service-summary-pills-margin-block-end, 2rem);
175
+
176
+ --theme-pill-bg: var(--service-summary-pill-bg, transparent);
177
+ --theme-pill-color: var(--service-summary-pill-colour, currentColor);
178
+ --theme-pill-border-color: var(--service-summary-pill-border-colour, currentColor);
179
+ }
180
+ }
181
+ }
182
+ }
183
+ </style>
@@ -1,15 +1,14 @@
1
- import ServicesSection from "../ServicesSection.vue";
1
+ import ServiceSummary from "../ServiceSummary.vue";
2
2
  import PageRow from "../../../../01.atoms/page-row/PageRow.vue";
3
3
  import TextBlock from "../../../../01.atoms/text-block/TextBlock.vue";
4
4
  import EyebrowText from "../../../../01.atoms/text-blocks/eyebrow-text/EyebrowText.vue";
5
5
  import HeroText from "../../../../01.atoms/text-blocks/hero-text/HeroText.vue";
6
- import InputButtonCore from "../../../../05.forms/input-button/InputButtonCore.vue";
7
6
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
8
7
  import type { Service } from "~/types/types.services";
9
8
 
10
- const meta: Meta<typeof ServicesSection> = {
11
- title: "Organisms/Services/Services Section",
12
- component: ServicesSection,
9
+ const meta: Meta<typeof ServiceSummary> = {
10
+ title: "Organisms/Services/Service Summary",
11
+ component: ServiceSummary,
13
12
  argTypes: {
14
13
  tag: {
15
14
  control: { type: "select" },
@@ -21,51 +20,15 @@ const meta: Meta<typeof ServicesSection> = {
21
20
  options: ["h1", "h2", "h3"],
22
21
  description: "Heading element used for the service title",
23
22
  },
24
- isSummary: {
25
- control: { type: "boolean" },
26
- description: "Summary mode — compact view with summary-link slot",
27
- },
28
- summaryAlignment: {
23
+ alignment: {
29
24
  control: { type: "select" },
30
25
  options: ["start", "center", "end"],
31
- description: "Vertical alignment of the info column in summary mode",
26
+ description: "Vertical alignment of the info column relative to the image",
32
27
  },
33
28
  reverse: {
34
29
  control: { type: "boolean" },
35
30
  description: "Swap image and content columns",
36
31
  },
37
- durationIcon: {
38
- control: { type: "text" },
39
- description: "Icon name for the duration row (any Iconify icon)",
40
- },
41
- priceIcon: {
42
- control: { type: "text" },
43
- description: "Icon name for the price row (any Iconify icon)",
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
- },
69
32
  styleClassPassthrough: {
70
33
  control: "object",
71
34
  description: "Additional CSS classes applied to the root element",
@@ -74,25 +37,22 @@ const meta: Meta<typeof ServicesSection> = {
74
37
  args: {
75
38
  tag: "section",
76
39
  headerTag: "h2",
77
- isSummary: false,
78
- summaryAlignment: "center",
40
+ alignment: "center",
79
41
  reverse: false,
80
- durationIcon: "mdi:clock-time-four-outline",
81
- priceIcon: "mdi:currency-gbp",
82
42
  styleClassPassthrough: [],
83
43
  },
84
44
  parameters: {
85
45
  docs: {
86
46
  description: {
87
47
  component:
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.",
48
+ "Renders a single service as a compact two-column preview: image on one side, eyebrow/title/price-duration pills/summary text on the other, with a summary-link slot for navigating to the full ServiceDetail page. Superseded ServicesSection's old full mode — a ServiceDetail page is the correct place for process/ideal-for/FAQ/CTA content now.",
89
49
  },
90
50
  },
91
51
  },
92
52
  };
93
53
 
94
54
  export default meta;
95
- type Story = StoryObj<typeof ServicesSection>;
55
+ type Story = StoryObj<typeof ServiceSummary>;
96
56
 
97
57
  // ─── Fixtures ─────────────────────────────────────────────────────────────────
98
58
 
@@ -153,136 +113,138 @@ const sampleService: Service = {
153
113
 
154
114
  // ─── Stories ──────────────────────────────────────────────────────────────────
155
115
 
156
- export const FullMode: Story = {
157
- name: "Full Mode",
158
- args: {
159
- ctaHeading: "Ready to book your appointment?",
160
- ctaBody: "Mobile service across Bath — I come to you.",
161
- },
116
+ export const Default: Story = {
117
+ name: "Default",
162
118
  render: (args) => ({
163
- components: { ServicesSection, InputButtonCore },
119
+ components: { ServiceSummary },
164
120
  setup() {
165
121
  return { args, sampleService };
166
122
  },
167
123
  template: `
168
- <ServicesSection v-bind="args" :service-data="sampleService">
169
- <template #cta>
170
- <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
124
+ <ServiceSummary v-bind="args" :service-data="sampleService">
125
+ <template #summary-link="{ serviceData }">
126
+ <a :href="'/services/' + serviceData.slug" style="color:inherit;">
127
+ More about {{ serviceData.title }} →
128
+ </a>
171
129
  </template>
172
- </ServicesSection>
130
+ </ServiceSummary>
173
131
  `,
174
132
  }),
175
133
  parameters: {
176
134
  docs: {
177
135
  description: {
178
- story:
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.",
136
+ story: "Eyebrow, title, price/duration pills, whatIsIt summary, and the summary-link slot.",
180
137
  },
181
138
  },
182
139
  },
183
140
  };
184
141
 
185
- export const SummaryMode: Story = {
186
- name: "Summary Mode",
142
+ export const Reversed: Story = {
143
+ name: "Reversed Layout",
187
144
  args: {
188
- isSummary: true,
145
+ reverse: true,
189
146
  },
190
147
  render: (args) => ({
191
- components: { ServicesSection },
148
+ components: { ServiceSummary },
192
149
  setup() {
193
150
  return { args, sampleService };
194
151
  },
195
- template: `
196
- <ServicesSection v-bind="args" :service-data="sampleService">
197
- <template #summary-link="{ serviceData }">
198
- <a :href="'/services/' + serviceData.slug" style="color:inherit;">
199
- More about {{ serviceData.title }} →
200
- </a>
201
- </template>
202
- </ServicesSection>
203
- `,
152
+ template: `<ServiceSummary v-bind="args" :service-data="sampleService" />`,
204
153
  }),
205
154
  parameters: {
206
155
  docs: {
207
156
  description: {
208
- story: "Summary mode — compact view with eyebrow, title, price/duration, whatIsIt, and summary-link slot.",
157
+ story: "Image on the right, content on the left — use on alternating items in a list.",
209
158
  },
210
159
  },
211
160
  },
212
161
  };
213
162
 
214
- export const Reversed: Story = {
215
- name: "Reversed Layout",
216
- args: {
217
- reverse: true,
218
- },
163
+ export const FixedRowHeight: Story = {
164
+ name: "Fixed Row Height",
219
165
  render: (args) => ({
220
- components: { ServicesSection },
166
+ components: { ServiceSummary },
221
167
  setup() {
222
168
  return { args, sampleService };
223
169
  },
224
- template: `<ServicesSection v-bind="args" :service-data="sampleService" />`,
170
+ template: `
171
+ <div style="--service-summary-height-mobile: 24rem; --service-summary-height-tablet: 28rem; --service-summary-height-desktop: 32rem;">
172
+ <ServiceSummary v-bind="args" :service-data="sampleService">
173
+ <template #summary-link="{ serviceData }">
174
+ <a :href="'/services/' + serviceData.slug" style="color:inherit;">
175
+ More about {{ serviceData.title }} →
176
+ </a>
177
+ </template>
178
+ </ServiceSummary>
179
+ </div>
180
+ `,
225
181
  }),
226
182
  parameters: {
227
183
  docs: {
228
184
  description: {
229
- story: "Image on the right, content on the left — use on alternating items in a list.",
185
+ story:
186
+ "--service-summary-height-mobile/-tablet/-desktop set a fixed height for the whole row (both the image and the text column) per @container breakpoint, instead of the default auto — the image crops via object-fit: cover to fill it (--service-summary-image-aspect-ratio no longer has any visible effect once this is set), and the text column gets real leftover space for the alignment prop to position within. Useful for keeping every row in a list a consistent height regardless of each service's source photo dimensions.",
230
187
  },
231
188
  },
232
189
  },
233
190
  };
234
191
 
235
- export const CustomIcons: Story = {
236
- name: "Custom Icons",
192
+ export const CustomAspectRatio: Story = {
193
+ name: "Custom Aspect Ratio",
237
194
  args: {
238
- durationIcon: "mdi:timer-outline",
239
- priceIcon: "mdi:currency-eur",
195
+ alignment: "start",
240
196
  },
241
197
  render: (args) => ({
242
- components: { ServicesSection },
198
+ components: { ServiceSummary },
243
199
  setup() {
244
200
  return { args, sampleService };
245
201
  },
246
- template: `<ServicesSection v-bind="args" :service-data="sampleService" />`,
202
+ template: `
203
+ <div style="--service-summary-image-aspect-ratio: 3 / 4;">
204
+ <ServiceSummary v-bind="args" :service-data="sampleService">
205
+ <template #summary-link="{ serviceData }">
206
+ <a :href="'/services/' + serviceData.slug" style="color:inherit;">
207
+ More about {{ serviceData.title }} →
208
+ </a>
209
+ </template>
210
+ </ServiceSummary>
211
+ </div>
212
+ `,
247
213
  }),
248
214
  parameters: {
249
215
  docs: {
250
216
  description: {
251
217
  story:
252
- "Custom icon names passed via durationIcon and priceIcon props — useful when the consuming app uses a different currency or wants a different clock style.",
218
+ "--service-summary-image-aspect-ratio (default 1 / 1, square) controls the image's own shape when the row height is left auto (the default) — here overridden to a taller 3 / 4 portrait crop. This token only has a visible effect while no --service-summary-height-* override forces a definite row height.",
253
219
  },
254
220
  },
255
221
  },
256
222
  };
257
223
 
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
- },
224
+ export const FramedImage: Story = {
225
+ name: "Framed Image (padding)",
268
226
  render: (args) => ({
269
- components: { ServicesSection, InputButtonCore },
227
+ components: { ServiceSummary },
270
228
  setup() {
271
229
  return { args, sampleService };
272
230
  },
273
231
  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>
232
+ <div style="--service-summary-image-padding-block-mobile: 1.6rem; --service-summary-image-padding-inline-mobile: 1.6rem; background: #1a1a1a;">
233
+ <ServiceSummary v-bind="args" :service-data="sampleService">
234
+ <template #summary-link="{ serviceData }">
235
+ <a :href="'/services/' + serviceData.slug" style="color:inherit;">
236
+ More about {{ serviceData.title }} →
237
+ </a>
238
+ </template>
239
+ </ServiceSummary>
240
+ </div>
279
241
  `,
280
242
  }),
281
243
  parameters: {
282
244
  docs: {
283
245
  description: {
284
246
  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.",
247
+ "--service-summary-image-padding-block-*/-inline-* insets the image within .service-summary__image-wrapper instead of letting it fill edge-to-edge — the wrapper's own background shows through as a frame. Combine with a background colour set on the wrapper (via styleClassPassthrough) for a card-like look.",
286
248
  },
287
249
  },
288
250
  },
@@ -290,12 +252,8 @@ export const CustomTextAndCta: Story = {
290
252
 
291
253
  export const RealisticPageContext: Story = {
292
254
  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
255
  render: (args) => ({
298
- components: { ServicesSection, PageRow, TextBlock, EyebrowText, HeroText, InputButtonCore },
256
+ components: { ServiceSummary, PageRow, TextBlock, EyebrowText, HeroText },
299
257
  setup() {
300
258
  return { args, sampleService };
301
259
  },
@@ -319,11 +277,13 @@ export const RealisticPageContext: Story = {
319
277
  </PageRow>
320
278
 
321
279
  <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" />
280
+ <ServiceSummary v-bind="args" :service-data="sampleService">
281
+ <template #summary-link="{ serviceData }">
282
+ <a :href="'/services/' + serviceData.slug" style="color:inherit;">
283
+ More about {{ serviceData.title }} →
284
+ </a>
325
285
  </template>
326
- </ServicesSection>
286
+ </ServiceSummary>
327
287
  </PageRow>
328
288
  </div>
329
289
  `,
@@ -332,37 +292,7 @@ export const RealisticPageContext: Story = {
332
292
  docs: {
333
293
  description: {
334
294
  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.",
295
+ "Matches the real services listing page (app/pages/services/index.vue in the luxury-locs-by-natasha-nuxt3 project): a page hero (EyebrowText + HeroText inside PageRow/TextBlock) followed by a ServiceSummary. Use ServiceSummaryGrid for the full alternating listing — this story sanity-checks a single instance's layout in context.",
366
296
  },
367
297
  },
368
298
  },