srcdev-nuxt-components 9.5.6 → 9.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.claude/skills/component-compliance-checklist.md +6 -2
  2. package/.claude/skills/components/breadcrumb.md +5 -0
  3. package/.claude/skills/components/display-pill.md +2 -0
  4. package/.claude/skills/components/input-button.md +3 -0
  5. package/.claude/skills/components/navigation-items.md +1 -0
  6. package/.claude/skills/components/opening-hours.md +12 -0
  7. package/.claude/skills/components/price-list.md +10 -1
  8. package/.claude/skills/components/responsive-header.md +74 -0
  9. package/.claude/skills/components/service-detail.md +32 -0
  10. package/.claude/skills/components/service-summary.md +8 -0
  11. package/.claude/skills/components/services-card-grid.md +5 -3
  12. package/.claude/skills/components/services-card.md +22 -12
  13. package/.claude/skills/components/site-header.md +2 -0
  14. package/.claude/skills/components/slider-gallery.md +10 -4
  15. package/.claude/skills/components/tab-navigation.md +25 -9
  16. package/.claude/skills/index.md +1 -2
  17. package/.claude/skills/storybook-add-story.md +14 -0
  18. package/.vscode/srcdev-component-responsive-header.code-snippets +13 -1
  19. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  20. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  21. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  22. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  23. package/.vscode/srcdev-component-site-header.code-snippets +3 -1
  24. package/.vscode/srcdev-component-tab-navigation.code-snippets +1 -1
  25. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +3 -0
  26. package/app/components/01.atoms/display-pill/DisplayPill.vue +5 -2
  27. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  28. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  29. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  30. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  31. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +52 -11
  32. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +55 -23
  33. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +176 -257
  34. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +42 -2
  35. package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
  36. package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
  37. package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
  38. package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
  39. package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
  40. package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
  41. package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
  42. package/app/components/02.molecules/price-list/PriceList.vue +32 -9
  43. package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
  44. package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
  45. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
  46. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  47. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  48. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  49. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  50. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  51. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +18 -0
  52. package/app/components/03.organisms/responsive-header/NavigationItems.vue +56 -18
  53. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +106 -31
  54. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +201 -18
  55. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
  56. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
  57. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
  58. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
  59. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
  60. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
  61. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
  62. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
  63. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
  64. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
  65. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
  66. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
  67. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
  68. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
  69. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
  70. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
  71. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  72. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +36 -0
  73. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +131 -6
  74. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
  75. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
  76. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  77. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  78. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  79. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  80. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -3
  81. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  82. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  83. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  84. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  85. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  86. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  87. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  88. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  89. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  90. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  91. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  92. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  93. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  94. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  95. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  96. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  97. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  98. package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
  99. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
  100. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  101. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  102. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  103. package/app/composables/useAnchorScroll.ts +1 -1
  104. package/app/utils/opening-hours.ts +23 -2
  105. package/nuxt.config.ts +1 -1
  106. package/package.json +1 -1
  107. package/.claude/skills/components/site-navigation.md +0 -145
  108. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  109. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  110. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  111. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  112. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  113. package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
@@ -0,0 +1,64 @@
1
+
2
+ /// <reference path="nitro-layouts.d.ts" />
3
+ /// <reference path="app.config.d.ts" />
4
+ /// <reference path="runtime-config.d.ts" />
5
+ /// <reference path="../../../../../../../node_modules/@nuxt/nitro-server/dist/index.d.mts" />
6
+ /// <reference path="middleware.d.ts" />
7
+
8
+ import type { RuntimeConfig } from 'nuxt/schema'
9
+ import type { H3Event } from 'h3'
10
+ import type { LogObject } from 'consola'
11
+ import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from 'nuxt/app'
12
+
13
+ declare module 'nitropack' {
14
+ interface NitroRuntimeConfigApp {
15
+ buildAssetsDir: string
16
+ cdnURL: string
17
+ }
18
+ interface NitroRuntimeConfig extends RuntimeConfig {}
19
+ interface NitroRouteConfig {
20
+ ssr?: boolean
21
+ noScripts?: boolean
22
+ /** @deprecated Use `noScripts` instead */
23
+ experimentalNoScripts?: boolean
24
+ }
25
+ interface NitroRouteRules {
26
+ ssr?: boolean
27
+ noScripts?: boolean
28
+ /** @deprecated Use `noScripts` instead */
29
+ experimentalNoScripts?: boolean
30
+ appMiddleware?: Record<string, boolean>
31
+ appLayout?: string | false
32
+ }
33
+ interface NitroRuntimeHooks {
34
+ 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
35
+ 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void>
36
+ 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void>
37
+ }
38
+ }
39
+ declare module 'nitropack/types' {
40
+ interface NitroRuntimeConfigApp {
41
+ buildAssetsDir: string
42
+ cdnURL: string
43
+ }
44
+ interface NitroRuntimeConfig extends RuntimeConfig {}
45
+ interface NitroRouteConfig {
46
+ ssr?: boolean
47
+ noScripts?: boolean
48
+ /** @deprecated Use `noScripts` instead */
49
+ experimentalNoScripts?: boolean
50
+ }
51
+ interface NitroRouteRules {
52
+ ssr?: boolean
53
+ noScripts?: boolean
54
+ /** @deprecated Use `noScripts` instead */
55
+ experimentalNoScripts?: boolean
56
+ appMiddleware?: Record<string, boolean>
57
+ appLayout?: string | false
58
+ }
59
+ interface NitroRuntimeHooks {
60
+ 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
61
+ 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void>
62
+ 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void>
63
+ }
64
+ }
@@ -0,0 +1,29 @@
1
+ // Generated by Nuxt'
2
+ import type { Plugin } from '#app'
3
+
4
+ type Decorate<T extends Record<string, any>> = { [K in keyof T as K extends string ? `$${K}` : never]: T[K] }
5
+
6
+ type InjectionType<A extends Plugin> = A extends {default: Plugin<infer T>} ? Decorate<T> : unknown
7
+
8
+ type NuxtAppInjections =
9
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/revive-payload.client.js")> &
10
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/head/runtime/plugins/unhead.js")> &
11
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/router.js")> &
12
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/navigation-repaint.client.js")> &
13
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/check-outdated-build.client.js")> &
14
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/revive-payload.server.js")> &
15
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/chunk-reload.client.js")>
16
+
17
+ declare module '#app' {
18
+ interface NuxtApp extends NuxtAppInjections { }
19
+
20
+ interface NuxtAppLiterals {
21
+ pluginName: 'nuxt:revive-payload:client' | 'nuxt:head' | 'nuxt:router' | 'nuxt:revive-payload:server' | 'nuxt:chunk-reload' | 'nuxt:global-components'
22
+ }
23
+ }
24
+
25
+ declare module 'vue' {
26
+ interface ComponentCustomProperties extends NuxtAppInjections { }
27
+ }
28
+
29
+ export { }
@@ -0,0 +1,32 @@
1
+ import { RuntimeConfig as UserRuntimeConfig, PublicRuntimeConfig as UserPublicRuntimeConfig } from 'nuxt/schema'
2
+ interface SharedRuntimeConfig {
3
+ app: {
4
+ buildId: string,
5
+
6
+ baseURL: string,
7
+
8
+ buildAssetsDir: string,
9
+
10
+ cdnURL: string,
11
+ },
12
+
13
+ nitro: {
14
+ envPrefix: string,
15
+ },
16
+ }
17
+ interface SharedPublicRuntimeConfig {
18
+
19
+ }
20
+ declare module '@nuxt/schema' {
21
+ interface RuntimeConfig extends UserRuntimeConfig {}
22
+ interface PublicRuntimeConfig extends UserPublicRuntimeConfig {}
23
+ }
24
+ declare module 'nuxt/schema' {
25
+ interface RuntimeConfig extends SharedRuntimeConfig {}
26
+ interface PublicRuntimeConfig extends SharedPublicRuntimeConfig {}
27
+ }
28
+ declare module 'vue' {
29
+ interface ComponentCustomProperties {
30
+ $config: UserRuntimeConfig
31
+ }
32
+ }
@@ -0,0 +1,10 @@
1
+ // Generated by auto imports
2
+ export {}
3
+ declare global {
4
+ const useRuntimeConfig: (event?: import('h3').H3Event) => import('nuxt/schema').RuntimeConfig
5
+ const useAppConfig: () => import('nuxt/schema').AppConfig
6
+ const defineAppConfig: <C extends import('nuxt/schema').AppConfigInput>(config: C) => C
7
+ const createError: typeof import('h3')['createError']
8
+ const setResponseStatus: typeof import('h3')['setResponseStatus']
9
+
10
+ }
@@ -146,10 +146,46 @@ describe("NavigationItems", () => {
146
146
  expect(details.attributes("open")).toBe("");
147
147
  });
148
148
 
149
+ it("builds the submenu aria-label from submenuAriaLabel, falling back to name", async () => {
150
+ const wrapper = await mountSuspended(NavigationItems, {
151
+ props: {
152
+ submenuAriaLabel: "Untermenü {title}",
153
+ mainNavigationState: {
154
+ clonedNavLinks: { firstNav: [{ name: "Services", childLinks: [{ name: "A", path: "/a" }] }] },
155
+ navListVisibility: { firstNav: false },
156
+ hasSecondNav: false,
157
+ },
158
+ },
159
+ });
160
+ const text = wrapper.find(".overflow-navigation-details .overflow-navigation-text");
161
+ expect(text.text()).toBe("Services");
162
+ expect(text.attributes("aria-label")).toBe("Untermenü Services");
163
+ });
164
+
149
165
  it("renders correct HTML structure with default props", async () => {
150
166
  const wrapper = await mountSuspended(NavigationItems, {
151
167
  props: { mainNavigationState },
152
168
  });
153
169
  expect(wrapper.html()).toMatchSnapshot();
154
170
  });
171
+
172
+ // ─── Same-page anchor links ─────────────────────────────────────────────
173
+
174
+ it("renders #anchor items as plain links, emits anchorClick and follows activeHash", async () => {
175
+ const wrapper = await mountSuspended(NavigationItems, {
176
+ props: {
177
+ activeHash: "#about",
178
+ mainNavigationState: {
179
+ clonedNavLinks: { firstNav: [{ name: "Home", path: "#home" }, { name: "About", path: "#about" }] },
180
+ navListVisibility: { firstNav: false },
181
+ hasSecondNav: false,
182
+ },
183
+ },
184
+ });
185
+ const links = wrapper.findAll(".overflow-navigation-link");
186
+ expect(links[0]!.attributes("href")).toBe("#home");
187
+ expect(wrapper.findAll(".overflow-navigation-item")[1]!.classes()).toContain("is-active");
188
+ await links[0]!.trigger("click");
189
+ expect(wrapper.emitted("anchorClick")?.[0]?.[1]).toBe("#home");
190
+ });
155
191
  });
@@ -34,6 +34,19 @@ const triggerGeometryPass = async () => {
34
34
  }
35
35
  };
36
36
 
37
+ // Fake layout: the main nav ends at mainNavRight and each item is 100px wide, laid out in
38
+ // document order, so item n (0-based) ends at (n + 1) * 100.
39
+ const mockGeometry = (mainNavRight: number) =>
40
+ vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
41
+ let right = 0;
42
+ if (this.classList.contains("main-navigation")) right = mainNavRight;
43
+ if (this.classList.contains("main-navigation-item")) {
44
+ const items = Array.from(this.closest(".main-navigation")?.querySelectorAll(".main-navigation-item") ?? []);
45
+ right = (items.indexOf(this) + 1) * 100;
46
+ }
47
+ return { left: 0, top: 0, bottom: 0, x: 0, y: 0, height: 0, right, width: right, toJSON: () => ({}) } as DOMRect;
48
+ });
49
+
37
50
  const navLinks: ResponsiveHeaderProp = {
38
51
  firstNav: [
39
52
  { name: "Home", path: "/" },
@@ -296,18 +309,90 @@ describe("ResponsiveHeader", () => {
296
309
  expect(overflowNav.text()).toContain("UI Components");
297
310
  });
298
311
 
299
- it("completes the geometry pass and marks all items visually-hidden under jsdom's zero-size layout", async () => {
312
+ it("hides only the items that end past the main nav's right edge", async () => {
313
+ const geometry = mockGeometry(250);
300
314
  const wrapper = await mountSuspended(ResponsiveHeader, {
301
315
  props: { responsiveNavLinks: navLinks },
302
316
  });
303
317
  await triggerGeometryPass();
304
318
 
305
- // jsdom reports every rect as 0×0, so `rect.right + margin + gap < wrapperRect.right`
306
- // (0 + 0 + 12 < 0) is always false — every item correctly collapses into overflow.
307
319
  expect(wrapper.find(".navigation").classes()).toContain("geometry-ready");
308
- const items = wrapper.findAll(".main-navigation-item");
309
- expect(items.length).toBeGreaterThan(0);
310
- items.forEach((item) => expect(item.classes()).toContain("visually-hidden"));
320
+ const hidden = wrapper.findAll(".main-navigation-item").map((item) => item.classes().includes("visually-hidden"));
321
+ expect(hidden).toEqual([false, false, true, true]);
322
+ expect(wrapper.find(".overflow-details").classes()).not.toContain("visually-hidden");
323
+ geometry.mockRestore();
324
+ });
325
+
326
+ it("keeps an item that ends exactly at the main nav's right edge, so a flush-right last item isn't always hidden", async () => {
327
+ const geometry = mockGeometry(400);
328
+ const wrapper = await mountSuspended(ResponsiveHeader, {
329
+ props: { responsiveNavLinks: navLinks },
330
+ });
331
+ await triggerGeometryPass();
332
+
333
+ wrapper.findAll(".main-navigation-item").forEach((item) => expect(item.classes()).not.toContain("visually-hidden"));
334
+ expect(wrapper.find(".overflow-details").classes()).toContain("visually-hidden");
335
+ geometry.mockRestore();
336
+ });
337
+
338
+ it("gives the icon-only overflow button an accessible name", async () => {
339
+ const wrapper = await mountSuspended(ResponsiveHeader, {
340
+ props: { responsiveNavLinks: navLinks },
341
+ });
342
+ expect(wrapper.find(".overflow-details-summary").attributes("aria-label")).toBe("More navigation");
343
+ await wrapper.setProps({ overflowButtonLabel: "Menü" });
344
+ expect(wrapper.find(".overflow-details-summary").attributes("aria-label")).toBe("Menü");
345
+ });
346
+
347
+ it("builds the submenu aria-label from submenuAriaLabel", async () => {
348
+ const wrapper = await mountSuspended(ResponsiveHeader, {
349
+ props: { responsiveNavLinks: navLinks, submenuAriaLabel: "Untermenü {title}" },
350
+ });
351
+ expect(wrapper.find(".main-navigation-details-summary").attributes("aria-label")).toBe("Untermenü UI Components");
352
+ });
353
+
354
+ it("falls back to name for a dropdown with no childLinksTitle", async () => {
355
+ const wrapper = await mountSuspended(ResponsiveHeader, {
356
+ props: { responsiveNavLinks: { firstNav: [{ name: "Services", childLinks: [{ name: "A", path: "/a" }] }] } },
357
+ });
358
+ const summary = wrapper.find(".main-navigation-details-summary");
359
+ expect(summary.text()).toBe("Services");
360
+ expect(summary.attributes("aria-label")).toBe("Services submenu");
361
+ });
362
+
363
+ it("renders child links with duplicate names without key clashes", async () => {
364
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
365
+ const wrapper = await mountSuspended(ResponsiveHeader, {
366
+ props: {
367
+ responsiveNavLinks: {
368
+ firstNav: [
369
+ { name: "Dup", childLinksTitle: "Dup", childLinks: [{ name: "Same", path: "/a" }, { name: "Same", path: "/b" }] },
370
+ ],
371
+ },
372
+ },
373
+ });
374
+ expect(wrapper.findAll(".main-navigation-sub-nav-link")).toHaveLength(2);
375
+ expect(warn.mock.calls.some(([msg]) => String(msg).includes("Duplicate keys"))).toBe(false);
376
+ warn.mockRestore();
377
+ });
378
+
379
+ it("shows the overflow button for any group key once an item is hidden", async () => {
380
+ const geometry = mockGeometry(150);
381
+ const wrapper = await mountSuspended(ResponsiveHeader, {
382
+ props: { responsiveNavLinks: { main: [{ name: "Home", path: "/" }, { name: "About", path: "/about" }] } },
383
+ });
384
+ await triggerGeometryPass();
385
+ expect(wrapper.find(".overflow-details").classes()).not.toContain("visually-hidden");
386
+ geometry.mockRestore();
387
+ });
388
+
389
+ it("keeps the overflow button hidden when there are no nav items", async () => {
390
+ const wrapper = await mountSuspended(ResponsiveHeader, {
391
+ props: { responsiveNavLinks: {} },
392
+ });
393
+ await triggerGeometryPass();
394
+ expect(wrapper.find(".navigation").classes()).toContain("geometry-ready");
395
+ expect(wrapper.find(".overflow-details").classes()).toContain("visually-hidden");
311
396
  });
312
397
 
313
398
  it("renders correct HTML structure with default props", async () => {
@@ -316,4 +401,44 @@ describe("ResponsiveHeader", () => {
316
401
  });
317
402
  expect(wrapper.html()).toMatchSnapshot();
318
403
  });
404
+
405
+ // ─── Same-page anchor links ─────────────────────────────────────────────
406
+
407
+ describe("anchor links", () => {
408
+ const anchorLinks: ResponsiveHeaderProp = {
409
+ firstNav: [
410
+ { name: "Home", path: "#home" },
411
+ { name: "About", path: "#about" },
412
+ { name: "Blog", path: "/blog" },
413
+ ],
414
+ };
415
+
416
+ it("renders #anchor paths as plain links and route paths as NuxtLinks", async () => {
417
+ const wrapper = await mountSuspended(ResponsiveHeader, { props: { responsiveNavLinks: anchorLinks } });
418
+ const links = wrapper.findAll(".main-navigation-link");
419
+ expect(links[0]!.attributes("href")).toBe("#home");
420
+ expect(links[2]!.attributes("href")).toBe("/blog");
421
+ });
422
+
423
+ it("marks the first anchor item active on load", async () => {
424
+ const wrapper = await mountSuspended(ResponsiveHeader, { props: { responsiveNavLinks: anchorLinks } });
425
+ await nextTick();
426
+ const items = wrapper.findAll(".main-navigation-item");
427
+ expect(items[0]!.classes()).toContain("is-active");
428
+ expect(items[1]!.classes()).not.toContain("is-active");
429
+ });
430
+
431
+ it("moves the active item to a clicked anchor and scrolls instead of jumping", async () => {
432
+ vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
433
+ const section = document.createElement("section");
434
+ section.id = "about";
435
+ section.scrollIntoView = vi.fn();
436
+ document.body.appendChild(section);
437
+ const wrapper = await mountSuspended(ResponsiveHeader, { props: { responsiveNavLinks: anchorLinks } });
438
+ await wrapper.findAll(".main-navigation-link")[1]!.trigger("click");
439
+ expect(section.scrollIntoView).toHaveBeenCalled();
440
+ expect(wrapper.findAll(".main-navigation-item")[1]!.classes()).toContain("is-active");
441
+ section.remove();
442
+ });
443
+ });
319
444
  });
@@ -4,11 +4,11 @@ exports[`ResponsiveHeader > renders correct HTML structure with default props 1`
4
4
  "<div class="navigation loaded" role="banner">
5
5
  <nav class="main-navigation is-animated" aria-label="Main navigation">
6
6
  <ul class="main-navigation-list">
7
- <li class="main-navigation-item visually-hidden is-active" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="0"><a href="/" class="main-navigation-link">
7
+ <li class="main-navigation-item visually-hidden is-active" data-group-key="firstNav" data-local-index="0"><a href="/" class="main-navigation-link">
8
8
  <!--v-if--> Home
9
9
  </a></li>
10
- <li class="main-navigation-item visually-hidden" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="1"><a href="/about" class="main-navigation-link has-icon"><span class="iconify i-mdi:home decorator-icon" aria-hidden="true"></span> About</a></li>
11
- <li class="main-navigation-item visually-hidden" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="2">
10
+ <li class="main-navigation-item visually-hidden" data-group-key="firstNav" data-local-index="1"><a href="/about" class="main-navigation-link has-icon"><span class="iconify i-mdi:home decorator-icon" aria-hidden="true"></span> About</a></li>
11
+ <li class="main-navigation-item visually-hidden" data-group-key="firstNav" data-local-index="2">
12
12
  <details class="main-navigation-details" name="navigation-group">
13
13
  <summary class="main-navigation-details-summary has-toggle-icon" aria-label="UI Components submenu"><span class="iconify i-mdi:chevron-down icon" aria-hidden="true"></span>
14
14
  <!--v-if--> UI Components
@@ -23,7 +23,7 @@ exports[`ResponsiveHeader > renders correct HTML structure with default props 1`
23
23
  </li>
24
24
  </ul>
25
25
  <ul class="main-navigation-list">
26
- <li class="main-navigation-item visually-hidden" style="--_main-navigation-item-width: 0px;" data-group-key="secondNav" data-local-index="0"><a href="/contact" class="main-navigation-link">
26
+ <li class="main-navigation-item visually-hidden" data-group-key="secondNav" data-local-index="0"><a href="/contact" class="main-navigation-link">
27
27
  <!--v-if--> Contact
28
28
  </a></li>
29
29
  </ul>
@@ -32,13 +32,13 @@ exports[`ResponsiveHeader > renders correct HTML structure with default props 1`
32
32
  </nav>
33
33
  <nav class="secondary-navigation" aria-label="Secondary navigation">
34
34
  <details class="overflow-details visually-hidden" name="overflow-group">
35
- <summary class="overflow-details-summary has-toggle-icon"><span class="iconify i-gravity-ui:ellipsis icon show" aria-hidden="true"></span><span class="iconify i-gravity-ui:bars icon" aria-hidden="true"></span></summary>
35
+ <summary class="overflow-details-summary has-toggle-icon" aria-label="More navigation"><span class="iconify i-gravity-ui:ellipsis icon show" aria-hidden="true"></span><span class="iconify i-gravity-ui:bars icon" aria-hidden="true"></span></summary>
36
36
  <div class="overflow-details-nav" role="menu">
37
37
  <div class="overflow-navigation-wrapper" role="menu" aria-label="Overflow navigation menu">
38
38
  <ul class="overflow-navigation-list visible" style="--_overflow-navigation-list-min-width: 0px;" role="none">
39
- <li class="overflow-navigation-item visible is-active" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="0" role="none"><a href="/" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Home</span></a></li>
40
- <li class="overflow-navigation-item visible" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="1" role="none"><a href="/about" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">About</span></a></li>
41
- <li class="overflow-navigation-item visible" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="2" role="none">
39
+ <li class="overflow-navigation-item visible is-active" data-group-key="firstNav" data-local-index="0" role="none"><a href="/" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Home</span></a></li>
40
+ <li class="overflow-navigation-item visible" data-group-key="firstNav" data-local-index="1" role="none"><a href="/about" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">About</span></a></li>
41
+ <li class="overflow-navigation-item visible" data-group-key="firstNav" data-local-index="2" role="none">
42
42
  <div class="expanding-panel-classic overflow-navigation-details">
43
43
  <details class="expanding-panel-classic-details" name="overflow-navigation-group">
44
44
  <summary id="id-overflow-navigation-group-trigger" class="expanding-panel-classic-summary" aria-controls="id-overflow-navigation-group-content" aria-expanded="false">
@@ -61,7 +61,7 @@ exports[`ResponsiveHeader > renders correct HTML structure with default props 1`
61
61
  </li>
62
62
  </ul>
63
63
  <ul class="overflow-navigation-list visible" style="--_overflow-navigation-list-min-width: 0px;" role="none">
64
- <li class="overflow-navigation-item visible" style="--_main-navigation-item-width: 0px;" data-group-key="secondNav" data-local-index="0" role="none"><a href="/contact" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Contact</span></a></li>
64
+ <li class="overflow-navigation-item visible" data-group-key="secondNav" data-local-index="0" role="none"><a href="/contact" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Contact</span></a></li>
65
65
  </ul>
66
66
  <div aria-hidden="true" class="overflow-nav-indicator-hovered"></div>
67
67
  <div aria-hidden="true" class="overflow-nav-indicator-active"></div>
@@ -0,0 +1 @@
1
+ {"uuid":"21163df5-50d7-4cfc-ab56-45d835fb0c72"}
@@ -1,40 +1,8 @@
1
+ # ServiceDetail — Consumer Styling Guide
1
2
 
2
3
  > **Changed 2026-09-27**: `--service-detail-hero-pill-bg` (and the hero text colour on the pills)
3
4
  > never applied before, because `DisplayPill`'s `neutral` variant ignored the base tokens. It now
4
5
  > falls back to them, so the hero pills are transparent with the hero text colour as documented.
5
- # ServiceDetail — Consumer Styling Guide
6
-
7
- ## Layout building blocks
8
-
9
- The hero banner is a [GridStack](../../../../../.claude/skills/components/grid-stack.md) with two
10
- stacked layers — an `image` layer (the service photo) behind a `content` layer (the scrim +
11
- breadcrumb/title/pills). Each layer's root element is itself a
12
- [PageRow](../../../../../.claude/skills/components/page-row.md): `.service-detail__hero-image-row`
13
- (prop `heroImageVariant`, default `"full"`) wraps the photo, and `.service-detail__hero-overlay`
14
- (prop `heroContentVariant`, default `"content"`) *is* the scrim/alignment element, not a wrapper
15
- around one — a `PageRow`'s own box always spans the full width of its parent regardless of variant,
16
- only its children default into the variant's grid column, so the one element can carry the
17
- full-bleed scrim while its child (`.service-detail__hero-content`) still gets pulled to the
18
- configured column. This means the photo bleeds to whatever width `.service-detail` itself is allowed
19
- (edge-to-edge if `ServiceDetail` is placed without a content-constraining wrapper around it), while
20
- the breadcrumb/title/pills always stay pulled into the page's normal content-width column,
21
- regardless of how wide the photo bleeds. `--service-detail-hero-padding` now only controls
22
- **block** (vertical) padding on the scrim — horizontal alignment of the text is handled entirely by
23
- `.service-detail__hero-overlay`'s own `PageRow` grid, not by padding, so it can't fight the page's
24
- content-grid gutters.
25
-
26
- The two-column body (main content + sticky sidebar) is wrapped in its own `PageRow`, whose variant
27
- is a prop: `bodyVariant` (`"full" | "popout" | "content" | "inset-content"`, default `"content"`).
28
- The closing full-width CTA banner is wrapped the same way via `finalCtaVariant` (same type, same
29
- `"content"` default). This means a consumer no longer needs to wrap `ServiceDetail` itself in an
30
- outer `PageRow` purely to constrain either section's width — pass `body-variant`/`final-cta-variant`
31
- directly. It's still fine (and necessary for a genuine full-bleed hero) to place `ServiceDetail`
32
- inside an outer, wider `PageRow` — see
33
- [service-summary.md](../../../../../.claude/skills/components/service-summary.md)-style nesting
34
- notes in [page-row.md](../../../../../.claude/skills/components/page-row.md) for how nested page-rows
35
- compose.
36
-
37
- ---
38
6
 
39
7
  ## Public token API
40
8
 
@@ -44,12 +12,25 @@ compose.
44
12
  |---|---|---|
45
13
  | `--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) |
46
14
  | `--service-detail-hero-content-align` | `end` | `align-content` on `.service-detail__hero-content` (the breadcrumb/title/pills wrapper) — where that content sits within the hero's block axis (`end` = bottom-anchored) |
47
- | `--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) |
48
- | `--service-detail-hero-min-height-tablet` | `36rem` | Hero banner height from the `768px` container breakpoint |
49
- | `--service-detail-hero-min-height-desktop` | `42rem` | Hero banner height from the `1024px` container breakpoint |
15
+ | `--service-detail-hero-min-height-mobile` | `32rem` | Minimum hero banner height below the `768px` container breakpoint. The hero grows past it when its content (a long title, say) needs more room; the image always fills the hero (`object-fit`/`object-position` control cropping) |
16
+ | `--service-detail-hero-min-height-tablet` | `36rem` | Minimum hero banner height from the `768px` container breakpoint |
17
+ | `--service-detail-hero-min-height-desktop` | `42rem` | Minimum hero banner height from the `1024px` container breakpoint |
18
+
19
+ > Changed 2026-10-06: these were applied as a fixed `height`, so a long title was clipped by the
20
+ > hero. They are now a true minimum (`min-block-size`), matching their names.
21
+ >
22
+ > Also 2026-10-06: line-clamp tokens for the hero eyebrow and title, sidebar labels and values,
23
+ > and the final CTA heading and body. The hero title's and final CTA heading's spacing moved from
24
+ > utility classes (`mb-20`, `mbs-0`/`mbe-8`) into the component CSS, same values, so the clamp can
25
+ > keep descenders and italic overhang visible.
50
26
  | `--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 |
51
27
  | `--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 |
52
28
  | `--service-detail-hero-text-colour` | `white` | Colour of the breadcrumb, eyebrow, and title over the hero image |
29
+ | `--service-detail-hero-eyebrow-line-clamp` | `none` | Max lines of the hero eyebrow (subtitle), ellipsis on the last. `1` is single-line ellipsis |
30
+ | `--service-detail-hero-title-line-clamp` | `none` | Max lines of the hero title |
31
+
32
+ For the breadcrumb, set Breadcrumb's own `--breadcrumb-item-line-clamp` (it reaches the hero
33
+ breadcrumb from any ancestor).
53
34
  | `--service-detail-breadcrumb-margin-block-end` | `0.8rem` | Space below the breadcrumb |
54
35
  | `--service-detail-hero-pills-gap` | `0.8rem` | Gap between the duration and price pills |
55
36
  | `--service-detail-hero-pill-bg` | `transparent` | Background of the price/duration pills (via `DisplayPill`'s `--display-pill-background`) |
@@ -100,8 +81,8 @@ target `--stepper-list-*` directly under `.service-detail__process` — see
100
81
  | `--service-detail-ideal-for-gap` | `1.2rem` | Gap between ideal-for cards |
101
82
  | `--service-detail-ideal-for-item-gap` | `1rem` | Gap between icon and text within a card |
102
83
  | `--service-detail-ideal-for-item-padding` | `1.6rem` | Padding inside a card |
103
- | `--service-detail-ideal-for-item-background` | `var(--slate-09)` | Card background colour |
104
- | `--service-detail-ideal-for-item-border-colour` | `var(--slate-06)` | Card border colour |
84
+ | `--service-detail-ideal-for-item-background` | `var(--theme-surface-subtle)` | Card background colour |
85
+ | `--service-detail-ideal-for-item-border-colour` | `transparent` | Card border colour |
105
86
  | `--service-detail-ideal-for-item-border-radius` | `0.4rem` | Card corner rounding |
106
87
  | `--service-detail-ideal-for-icon-size` | `1.6rem` | Icon size |
107
88
  | `--service-detail-ideal-for-icon-colour` | `var(--colour-text-accent)` | Icon colour |
@@ -129,17 +110,28 @@ The ideal-for grid becomes 2 columns at a **500px container width**.
129
110
  | `--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) |
130
111
  | `--service-detail-sidebar-row-book-cta-justify-content` | `end` | Horizontal alignment of the `book-cta` slot content within its row |
131
112
  | `--service-detail-sidebar-row-label-font-size` | `1.2rem` | Font size of a row's label (e.g. "Price") |
113
+ | `--service-detail-sidebar-label-line-clamp` | `none` | Max lines of the "Book This Service" / "You May Also Like" labels |
114
+ | `--service-detail-sidebar-value-line-clamp` | `none` | Max lines of a row's value (price, duration, location) |
115
+
116
+ A row's label takes the width of its own words (up to half the row) and wraps between words only;
117
+ the value takes the rest and wraps anywhere.
132
118
  | `--service-detail-sidebar-note-font-size` | `1.2rem` | Font size of the `sidebar-note` slot content |
133
119
  | `--service-detail-sidebar-note-margin-block-start` | `1.2rem` | Space above the `sidebar-note` slot content |
134
120
  | `--service-detail-related-items-gap` | `1rem` | Gap between related-service item cards |
135
- | `--service-detail-related-item-background` | `var(--slate-09)` | Background colour of a related-service item's card |
121
+ | `--service-detail-related-item-background` | `var(--theme-surface-subtle)` | Background colour of a related-service item's card |
136
122
  | `--service-detail-related-item-padding` | `1rem` | Padding inside a related-service item's card |
137
- | `--service-detail-related-item-border-colour` | `var(--slate-06)` | Border colour of a related-service item's card |
123
+ | `--service-detail-related-item-border-colour` | `transparent` | Border colour of a related-service item's card |
138
124
  | `--service-detail-related-item-border-radius` | `0.4rem` | Corner rounding of a related-service item's card |
139
125
  | `--service-detail-related-item-gap` | `1.2rem` | Gap between a related item's thumbnail and its text |
140
126
  | `--service-detail-related-image-size` | `5.6rem` | Width/height of a related-service thumbnail |
141
127
  | `--service-detail-related-image-border-radius` | `0.4rem` | Corner rounding of a related-service thumbnail |
142
128
  | `--service-detail-related-price-font-size` | `1.2rem` | Font size of a related item's price |
129
+ | `--service-detail-related-title-line-clamp` | `none` | Max lines of a related item's title (default item only, not the `related-service` slot). `1` is single-line ellipsis |
130
+
131
+ > Changed 2026-10-06: the ideal-for and related-service cards defaulted to dark `--slate-09` with a
132
+ > `--slate-06` border but set no text colour, so on a light page their inherited dark text was
133
+ > unreadable. They now default to `var(--theme-surface-subtle)` with a transparent border. To keep
134
+ > dark cards, set the four `-background`/`-border-colour` tokens.
143
135
  | `--service-detail-related-price-margin-block-start` | `0.6rem` | Space between a related item's title and its price |
144
136
  | `--service-detail-muted-opacity` | `0.7` | Opacity shared by every secondary/muted text element (sidebar label, sidebar note, related price, final CTA body) |
145
137
 
@@ -153,6 +145,11 @@ The booking card is a `GlassPanel` — its own `--glass-panel-*` tokens also app
153
145
  | `--service-detail-final-cta-margin-block-start` | `3.2rem` | Space above the banner |
154
146
  | `--service-detail-final-cta-padding-block` | `3.2rem 3.2rem` | Padding (block-start block-end) around the banner content, below its divider |
155
147
  | `--service-detail-final-cta-divider-colour` | `currentColor` | Divider line above the banner |
148
+ | `--service-detail-final-cta-heading-line-clamp` | `none` | Max lines of the banner heading |
149
+ | `--service-detail-final-cta-body-line-clamp` | `none` | Max lines of the banner body text |
150
+
151
+ The `final-cta` slot sits in `.service-detail__final-cta-action`, which never shrinks: long copy
152
+ wraps beside it instead of pushing the button out of the banner.
156
153
 
157
154
  The banner becomes a single row (copy left, CTA right) at a **700px container width**. Its own
158
155
  breakout width is controlled by the `finalCtaVariant` prop (default `"content"`), not a token —
@@ -160,6 +157,47 @@ see Layout building blocks above.
160
157
 
161
158
  ---
162
159
 
160
+ ## State hooks
161
+
162
+ No state classes. Inner classes are all `service-detail__*` (hero, hero image/overlay/content, breadcrumb,
163
+ hero pills, body, main, process, ideal-for and its items/icon, faqs and each faq/question, sidebar,
164
+ booking card, sidebar label/rows/note, related items, final CTA). FAQ questions render one heading level
165
+ below `subheadingTag` (`h3` for the default `h2`, `h4` for `h3`).
166
+
167
+ ---
168
+
169
+ ## Layout building blocks
170
+
171
+ The hero banner is a [GridStack](../../../../../.claude/skills/components/grid-stack.md) with two
172
+ stacked layers — an `image` layer (the service photo) behind a `content` layer (the scrim +
173
+ breadcrumb/title/pills). Each layer's root element is itself a
174
+ [PageRow](../../../../../.claude/skills/components/page-row.md): `.service-detail__hero-image-row`
175
+ (prop `heroImageVariant`, default `"full"`) wraps the photo, and `.service-detail__hero-overlay`
176
+ (prop `heroContentVariant`, default `"content"`) *is* the scrim/alignment element, not a wrapper
177
+ around one — a `PageRow`'s own box always spans the full width of its parent regardless of variant,
178
+ only its children default into the variant's grid column, so the one element can carry the
179
+ full-bleed scrim while its child (`.service-detail__hero-content`) still gets pulled to the
180
+ configured column. This means the photo bleeds to whatever width `.service-detail` itself is allowed
181
+ (edge-to-edge if `ServiceDetail` is placed without a content-constraining wrapper around it), while
182
+ the breadcrumb/title/pills always stay pulled into the page's normal content-width column,
183
+ regardless of how wide the photo bleeds. `--service-detail-hero-padding` now only controls
184
+ **block** (vertical) padding on the scrim — horizontal alignment of the text is handled entirely by
185
+ `.service-detail__hero-overlay`'s own `PageRow` grid, not by padding, so it can't fight the page's
186
+ content-grid gutters.
187
+
188
+ The two-column body (main content + sticky sidebar) is wrapped in its own `PageRow`, whose variant
189
+ is a prop: `bodyVariant` (`"full" | "popout" | "content" | "inset-content"`, default `"content"`).
190
+ The closing full-width CTA banner is wrapped the same way via `finalCtaVariant` (same type, same
191
+ `"content"` default). This means a consumer no longer needs to wrap `ServiceDetail` itself in an
192
+ outer `PageRow` purely to constrain either section's width — pass `body-variant`/`final-cta-variant`
193
+ directly. It's still fine (and necessary for a genuine full-bleed hero) to place `ServiceDetail`
194
+ inside an outer, wider `PageRow` — see
195
+ [service-summary.md](../../../../../.claude/skills/components/service-summary.md)-style nesting
196
+ notes in [page-row.md](../../../../../.claude/skills/components/page-row.md) for how nested page-rows
197
+ compose.
198
+
199
+ ---
200
+
163
201
  ## Text content — props, not CSS
164
202
 
165
203
  Section headings, sidebar labels, and CTA copy are **props**, not hardcoded strings:
@@ -177,6 +215,14 @@ Section headings, sidebar labels, and CTA copy are **props**, not hardcoded stri
177
215
  | `relatedServicesHeading` | `"You May Also Like"` |
178
216
  | `finalCtaHeading` | `"Ready to book your appointment?"` |
179
217
  | `finalCtaBody` | `"Get in touch to book your appointment."` |
218
+ | `pricePrefix` | `"From"` (hero price pill; `""` shows the price alone) |
219
+ | `breadcrumbAriaLabel` | Breadcrumb's own default |
220
+ | `idealForIcon` | `"mdi:diamond-stone"` (decorative, hidden from assistive tech) |
221
+
222
+ A section is left out, heading and all, when its data is empty (`process`, `idealFor`, `faqs`,
223
+ `maintenance`, a `heroHeading` with no text), and so is any heading or label prop set to `""`. Empty
224
+ `subtitle`, `title`, `price`, `duration`, `longDescription`, `whatIsIt` and FAQ questions/answers render
225
+ nothing, and empty breadcrumb labels are dropped.
180
226
 
181
227
  ```vue
182
228
  <ServiceDetail
@@ -226,6 +272,12 @@ never reach the hero pills or breadcrumb. Use the `--service-detail-hero-*` toke
226
272
 
227
273
  ---
228
274
 
275
+ ## Class passthrough
276
+
277
+ `style-class-passthrough` adds classes to the root `.service-detail`. Reactive after mount.
278
+
279
+ ---
280
+
229
281
  ## Notes
230
282
 
231
283
  - Routing is entirely delegated to the consumer via slots: `book-cta` (scoped `serviceData`),
@@ -251,4 +303,3 @@ never reach the hero pills or breadcrumb. Use the `--service-detail-hero-*` toke
251
303
  `@container` queries against the component's own width, not the viewport — they are not tokens
252
304
  since resizing them would need corresponding `minmax()`/`grid-template-columns` changes too, not
253
305
  just spacing (same rationale as `ServiceSummary`'s `--service-summary-grid-gap-desktop`).
254
-