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
@@ -1,7 +1,10 @@
1
+ import { computed, ref } from "vue";
1
2
  import ServiceDetail from "../ServiceDetail.vue";
3
+ import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
4
  import InputButton from "../../../../05.forms/input-button/InputButton.vue";
3
5
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
4
6
  import type { Service } from "~/types/types.services";
7
+ import type { MediaCanvas } from "~/types/components";
5
8
 
6
9
  const meta: Meta<typeof ServiceDetail> = {
7
10
  title: "Organisms/Services/Service Detail",
@@ -60,6 +63,9 @@ const meta: Meta<typeof ServiceDetail> = {
60
63
  relatedServices: { control: "object", description: "Services rendered in the 'You may also like' sidebar list" },
61
64
  finalCtaHeading: { control: "text", description: "Heading in the closing full-width CTA banner" },
62
65
  finalCtaBody: { control: "text", description: "Body text in the closing full-width CTA banner" },
66
+ pricePrefix: { control: "text", description: "Text before the price in the hero pill; empty shows the price alone" },
67
+ idealForIcon: { control: "text", description: "Icon shown on each ideal-for item" },
68
+ breadcrumbAriaLabel: { control: "text", description: "aria-label on the breadcrumb nav (Breadcrumb's default when unset)" },
63
69
  styleClassPassthrough: { control: "object", description: "Additional CSS classes applied to the root element" },
64
70
  },
65
71
  args: {
@@ -70,15 +76,11 @@ const meta: Meta<typeof ServiceDetail> = {
70
76
  heroContentVariant: "content",
71
77
  bodyVariant: "content",
72
78
  finalCtaVariant: "content",
79
+ pricePrefix: "From",
80
+ idealForIcon: "mdi:diamond-stone",
73
81
  styleClassPassthrough: [],
74
82
  },
75
83
  parameters: {
76
- backgrounds: {
77
- default: "storybook-canvas",
78
- options: {
79
- "storybook-canvas": { name: "Canvas", value: "oklch(0.163 0.005 17)" },
80
- },
81
- },
82
84
  docs: {
83
85
  description: {
84
86
  component:
@@ -154,13 +156,10 @@ const relatedServices: Service[] = [
154
156
  // ServiceDetail's own --service-detail-*/--glass-panel-*/--breadcrumb-* tokens (see
155
157
  // CONSUMER-STYLING.md) rather than new component defaults.
156
158
  const storyTheme = {
157
- "--glass-panel-bg": "oklch(0.216 0.006 40)",
158
- "--glass-panel-border-color": "oklch(0.283 0.008 44)",
159
159
  "--service-detail-process-divider-colour": "oklch(0.283 0.008 44)",
160
160
  "--service-detail-faq-divider-colour": "oklch(0.283 0.008 44)",
161
161
  "--service-detail-sidebar-row-divider-colour": "oklch(0.283 0.008 44)",
162
162
  "--service-detail-final-cta-divider-colour": "oklch(0.283 0.008 44)",
163
- "--service-detail-ideal-for-item-border-colour": "oklch(0.283 0.008 44)",
164
163
  "--service-detail-process-index-colour": "oklch(0.727 0.089 87)",
165
164
  "--service-detail-ideal-for-icon-colour": "oklch(0.727 0.089 87)",
166
165
  "--service-detail-hero-pill-border-colour": "oklch(0.727 0.089 87)",
@@ -304,3 +303,152 @@ export const CustomRelatedServiceLink: Story = {
304
303
  },
305
304
  },
306
305
  };
306
+
307
+ // ─── Stress test ──────────────────────────────────────────────────────────────
308
+
309
+ type LineClamp = "none" | "1" | "2" | "3" | "4";
310
+
311
+ // Story-only args: each drives a CSS token, not a ServiceDetail prop. See the argTypes entries.
312
+ const lineClampTokens = {
313
+ breadcrumbItemLineClamp: { token: "--breadcrumb-item-line-clamp", label: "Breadcrumb item" },
314
+ heroEyebrowLineClamp: { token: "--service-detail-hero-eyebrow-line-clamp", label: "Hero eyebrow" },
315
+ heroTitleLineClamp: { token: "--service-detail-hero-title-line-clamp", label: "Hero title" },
316
+ sidebarLabelLineClamp: { token: "--service-detail-sidebar-label-line-clamp", label: "Sidebar label" },
317
+ sidebarValueLineClamp: { token: "--service-detail-sidebar-value-line-clamp", label: "Sidebar value" },
318
+ relatedTitleLineClamp: { token: "--service-detail-related-title-line-clamp", label: "Related title" },
319
+ finalCtaHeadingLineClamp: { token: "--service-detail-final-cta-heading-line-clamp", label: "Final CTA heading" },
320
+ finalCtaBodyLineClamp: { token: "--service-detail-final-cta-body-line-clamp", label: "Final CTA body" },
321
+ } as const;
322
+
323
+ type LineClampArg = keyof typeof lineClampTokens;
324
+
325
+ type StressArgs = InstanceType<typeof ServiceDetail>["$props"] & Record<LineClampArg, LineClamp>;
326
+
327
+ const longGerman = "Haarverlängerungsbehandlungsberatungsterminvereinbarung";
328
+ const longUrl = "https://example.com/a/very/long/path/that/never/breaks/because/it/has/no/spaces/at/all?utm_source=newsletter";
329
+ const germanParagraph = "Eine ausgesprochen lange Beschreibung, wie sie ein CMS liefert, wenn niemand die Zeichenanzahl prüft. ";
330
+
331
+ const stressService: Service = {
332
+ ...balayage,
333
+ slug: "stress",
334
+ category: `Kategorie ${longGerman}`,
335
+ subtitle: `Freihändige ${longGerman}`,
336
+ title: `${longGerman} und ${longGerman}`,
337
+ price: "95,00 € zuzüglich Pflegeprodukte nach individueller Absprache mit Ihrer Stylistin",
338
+ duration: "Ungefähr zweieinhalb bis dreieinhalb Stunden einschließlich ausführlicher Beratung",
339
+ longDescription: germanParagraph.repeat(10),
340
+ heroHeading: [
341
+ { text: "Warum", styleClass: "normal" },
342
+ { text: longGerman, styleClass: "accent" },
343
+ ],
344
+ whatIsIt: `🌈✨ <b>not bold</b> <script>alert(1)</script> ${longUrl}`,
345
+ process: [
346
+ longGerman,
347
+ "A",
348
+ "وصف طويل باللغة العربية يمتد عبر عدة أسطر للتحقق من أن النص من اليمين إلى اليسار يعرض بشكل صحيح.",
349
+ ...Array.from({ length: 9 }, (_, index) => `Schritt ${index + 4}: ${germanParagraph}`),
350
+ ],
351
+ idealFor: [longGerman, "😀 Emoji first", longUrl, "B", ...Array.from({ length: 8 }, (_, index) => `Filler ${index + 1}`)],
352
+ maintenance: germanParagraph.repeat(3),
353
+ faqs: [
354
+ { question: `${longGerman}?`, answer: germanParagraph.repeat(4) },
355
+ { question: "<i>Is this italic?</i>", answer: "" },
356
+ { question: "", answer: "An answer with no question." },
357
+ ],
358
+ };
359
+
360
+ const stressRelated: Service[] = [
361
+ { ...balayage, slug: "related-long", title: `${longGerman} ${longGerman}`, price: "Ab 1.250,00 € pro Sitzung" },
362
+ { ...balayage, slug: "related-long", title: "Duplicate slug", price: "£0" },
363
+ { ...balayage, slug: "related-broken", title: "Broken image", image: "https://example.invalid/missing.jpg", price: "" },
364
+ ];
365
+
366
+ export const StressTest: StoryObj<StressArgs> = {
367
+ name: "Stress Test (Worst-Case Data)",
368
+ argTypes: Object.fromEntries(
369
+ Object.entries(lineClampTokens).map(([arg, { token, label }]) => [
370
+ arg,
371
+ {
372
+ name: `${label} line clamp`,
373
+ control: "select",
374
+ options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
375
+ description:
376
+ `**Story control, not a prop.** Sets the \`${token}\` CSS token on the component so you can try it here. ` +
377
+ `To use it in an app, set the token in your own CSS, e.g. \`.service-page { ${token}: 2; }\`. ` +
378
+ "`1` is single-line ellipsis, `none` (the default) shows everything.",
379
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
380
+ },
381
+ ])
382
+ ),
383
+ args: {
384
+ tag: "section",
385
+ pricePrefix: "Ab einem Mindestpreis von",
386
+ idealForIcon: "this-icon:does-not-exist",
387
+ location: `Mobil im gesamten Großraum ${longGerman}, nach Vereinbarung auch an Wochenenden`,
388
+ bookingHeading: `Diese ${longGerman} jetzt buchen`,
389
+ finalCtaHeading: `Bereit für Ihre ${longGerman}?`,
390
+ finalCtaBody: germanParagraph.repeat(2),
391
+ ...(Object.fromEntries(Object.keys(lineClampTokens).map((arg) => [arg, "none"])) as Record<LineClampArg, LineClamp>),
392
+ },
393
+ render: (args) => ({
394
+ components: { ServiceDetail, InputButton },
395
+ setup() {
396
+ const componentArgs = computed(() =>
397
+ Object.fromEntries(Object.entries(args).filter(([arg]) => !(arg in lineClampTokens)))
398
+ );
399
+ const tokenStyles = computed(() => ({
400
+ ...storyTheme,
401
+ ...Object.fromEntries(
402
+ Object.entries(lineClampTokens).map(([arg, { token }]) => [token, args[arg as LineClampArg]])
403
+ ),
404
+ }));
405
+ return { componentArgs, tokenStyles, stressService, stressRelated };
406
+ },
407
+ template: `
408
+ <ServiceDetail v-bind="componentArgs" :service-data="stressService" :related-services="stressRelated" :style="tokenStyles">
409
+ <template #book-cta>
410
+ <InputButton tag="a" href="/contact" button-text="Jetzt unverbindlich einen Beratungstermin vereinbaren" variant="primary" />
411
+ </template>
412
+ <template #sidebar-note>
413
+ Ein Allergietest ist mindestens 48 Stunden vor jeder Farbbehandlung erforderlich: ${longUrl}
414
+ </template>
415
+ <template #final-cta>
416
+ <InputButton tag="a" href="/contact" button-text="Jetzt buchen" variant="secondary" />
417
+ </template>
418
+ </ServiceDetail>
419
+ `,
420
+ }),
421
+ decorators: [
422
+ (story, context) => ({
423
+ components: { story, CanvasSwitcher },
424
+ setup() {
425
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
426
+ return { canvasName };
427
+ },
428
+ template: `
429
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
430
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
431
+ </div>
432
+ <div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
433
+ <story />
434
+ </div>
435
+ `,
436
+ }),
437
+ ],
438
+ parameters: {
439
+ layout: "fullscreen",
440
+ initialCanvas: "mobileCanvas",
441
+ docs: {
442
+ description: {
443
+ story:
444
+ "Deliberately hostile data to find breakage: a hero title of two 55-character German compound words (the hero " +
445
+ "grows instead of clipping it), long German breadcrumb, eyebrow, pills, booking copy and location, a German price " +
446
+ "prefix, emoji, HTML-like text (must render as text), an unbroken URL, right-to-left Arabic, 12 process steps and " +
447
+ "12 ideal-for items, FAQs with an empty answer and an empty question, related services with a long title, a " +
448
+ "duplicate slug, a broken image and an empty price, and a broken ideal-for icon name. Check at every canvas width: " +
449
+ "no text runs out of its column, the sidebar values wrap and stay right-aligned, and the body switches to two " +
450
+ "columns from 900px. Try the line clamp controls too (breadcrumb items, hero eyebrow and title, sidebar labels and values, related titles, final CTA heading and body): they set CSS tokens, they are not props. The final CTA button never shrinks or gets pushed out by long copy.",
451
+ },
452
+ },
453
+ },
454
+ };
@@ -311,4 +311,62 @@ describe("ServiceDetail", () => {
311
311
  expect(wrapper.classes()).not.toContain("original");
312
312
  expect(wrapper.classes()).toContain("updated");
313
313
  });
314
+
315
+ // ─── Worst-case data ───────────────────────────────────────────────────
316
+
317
+ it("leaves out every section whose data is empty, along with its heading", async () => {
318
+ const wrapper = await mountSuspended(ServiceDetail, {
319
+ props: {
320
+ serviceData: {
321
+ ...mockService,
322
+ category: "",
323
+ subtitle: "",
324
+ title: "",
325
+ price: "",
326
+ duration: "",
327
+ longDescription: "",
328
+ heroHeading: [{ text: "", styleClass: "normal" }],
329
+ whatIsIt: "",
330
+ process: [],
331
+ idealFor: [],
332
+ maintenance: "",
333
+ faqs: [],
334
+ },
335
+ tag: "section",
336
+ },
337
+ });
338
+ expect(wrapper.find(".breadcrumb").exists()).toBe(false);
339
+ expect(wrapper.find(".service-detail__hero-pills").exists()).toBe(false);
340
+ expect(wrapper.find(".service-detail__process").exists()).toBe(false);
341
+ expect(wrapper.find(".service-detail__ideal-for").exists()).toBe(false);
342
+ expect(wrapper.find(".service-detail__faqs").exists()).toBe(false);
343
+ expect(wrapper.find(".service-detail__sidebar-row").exists()).toBe(false);
344
+ expect(wrapper.text()).not.toContain("The Process");
345
+ expect(wrapper.text()).not.toContain("Ideal For");
346
+ expect(wrapper.text()).not.toContain("Aftercare & Maintenance");
347
+ expect(wrapper.text()).not.toContain("Frequently Asked Questions");
348
+ expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
349
+ });
350
+
351
+ it("uses pricePrefix for the hero price pill", async () => {
352
+ const wrapper = await mountSuspended(ServiceDetail, {
353
+ props: { serviceData: mockService, pricePrefix: "Ab" },
354
+ });
355
+ expect(wrapper.find(".service-detail__hero-pills").text()).toContain(`Ab ${mockService.price}`);
356
+ });
357
+
358
+ it("puts FAQ questions one level below subheadingTag", async () => {
359
+ const wrapper = await mountSuspended(ServiceDetail, {
360
+ props: { serviceData: mockService, subheadingTag: "h3" },
361
+ });
362
+ expect(wrapper.find(".service-detail__faq-question").element.tagName).toBe("H4");
363
+ });
364
+
365
+ it("hides the decorative ideal-for icon and accepts a custom one", async () => {
366
+ const wrapper = await mountSuspended(ServiceDetail, {
367
+ props: { serviceData: mockService, idealForIcon: "mdi:check" },
368
+ });
369
+ const icon = wrapper.find(".service-detail__ideal-for-icon");
370
+ expect(icon.attributes("aria-hidden")).toBe("true");
371
+ });
314
372
  });
@@ -4,7 +4,7 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
4
4
  "<div class="service-detail">
5
5
  <div class="grid-stack service-detail__hero">
6
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>
7
+ <div class="page-row full service-detail__hero-image-row"><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/balayage.jpg 1x, /_ipx/s_3840x2160/images/balayage.jpg 2x" alt="Balayage" loading="eager" fetchpriority="high" class="service-detail__hero-image" src="/_ipx/s_1920x1080/images/balayage.jpg"></div>
8
8
  </div>
9
9
  <div class="grid-stack__layer">
10
10
  <div class="page-row content service-detail__hero-overlay">
@@ -18,7 +18,7 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
18
18
  </ol>
19
19
  </nav>
20
20
  <div class="eyebrow-text large">Freehand colour artistry</div>
21
- <h1 id="v-0-0" class="hero-text mb-20 display axis-horizontal">
21
+ <h1 id="v-0-0" class="hero-text display axis-horizontal">
22
22
  <!--v-if--><span class="text-block-0 normal">Balayage</span>
23
23
  </h1>
24
24
  <div class="service-detail__hero-pills"><span class="display-pill md neutral"><!--v-if--><span class="display-pill-label">2.5 - 3.5 hours</span></span><span class="display-pill md neutral"><!--v-if--><span class="display-pill-label">From £95</span></span></div>
@@ -98,11 +98,12 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
98
98
  <div class="page-row content service-detail__final-cta-row">
99
99
  <div class="service-detail__final-cta">
100
100
  <div class="service-detail__final-cta-copy">
101
- <h2 class="hero-text mbs-0 mbe-8 subheading axis-horizontal">
101
+ <h2 class="hero-text subheading axis-horizontal">
102
102
  <!--v-if--><span class="text-block-0 normal">Ready to book your appointment?</span>
103
103
  </h2>
104
104
  <p class="page-body-normal">Get in touch to book your appointment.</p>
105
105
  </div>
106
+ <!--v-if-->
106
107
  </div>
107
108
  </div>
108
109
  </div>"
@@ -29,6 +29,14 @@ theme file, scoped to a page wrapper, or per-instance via `styleClassPassthrough
29
29
  | `--service-summary-image-border-radius` | `0.8rem` | Corner rounding on the service image |
30
30
  | `--service-summary-pills-gap` | `0.8rem` | Gap between the duration and price pills |
31
31
  | `--service-summary-pills-margin-block-end` | `2rem` | Space below the pill row |
32
+ | `--service-summary-column-min-width` | `246px` | Minimum width of each column in the two-column layout (from the 768px container breakpoint), capped at the container width |
33
+ | `--service-summary-body-line-clamp` | `none` | Max lines of the `whatIsIt` summary text, with an ellipsis on the last. `1` is single-line ellipsis, `none` shows everything |
34
+
35
+ Long unbroken text wraps in every field. A pill longer than its column is capped at the column width
36
+ and ends in an ellipsis (that is `DisplayPill` behaviour).
37
+
38
+ > Changed 2026-10-06: added `--service-summary-column-min-width` (was a fixed `246px`) and
39
+ > `--service-summary-body-line-clamp`; columns can no longer be widened by unbroken text.
32
40
 
33
41
  All breakpoints above (row height, image padding, and the two-column grid switch) are
34
42
  **container queries** against `.service-summary`'s own inline size (`container-name:
@@ -57,6 +65,22 @@ under `.service-summary__pills` — see [display-pill.md](../../../../../.claude
57
65
 
58
66
  ---
59
67
 
68
+ ## State hooks
69
+
70
+ | Hook | When |
71
+ |---|---|
72
+ | `.service-summary__grid--reverse` | `reverse` is set: the image column moves after the text column from the 768px container breakpoint |
73
+ | `.service-summary__info-wrapper--align-start` / `-center` / `-end` | The `alignment` prop |
74
+
75
+ Inner classes: `.service-summary__grid`, `.service-summary__image-wrapper`, `.service-summary__image`,
76
+ `.service-summary__info-wrapper`, `.service-summary__pills`. The eyebrow, title and pills are
77
+ `EyebrowText`, `HeroText` and `DisplayPill`; the summary text is a `p.page-body-normal`.
78
+
79
+ Empty `subtitle`, `title`, `whatIsIt`, `duration` or `price` render no element (and no pill row when
80
+ both pill values are empty). With no title, a landmark `tag` gets no `aria-labelledby`.
81
+
82
+ ---
83
+
60
84
  ## Global theming
61
85
 
62
86
  Create `assets/styles/setup/07.components/service-summary.css` in the consuming app and
@@ -117,6 +141,12 @@ visual style, pass a modifier class:
117
141
 
118
142
  ---
119
143
 
144
+ ## Class passthrough
145
+
146
+ `style-class-passthrough` adds classes to the root `.service-summary`. Reactive after mount.
147
+
148
+ ---
149
+
120
150
  ## Notes
121
151
 
122
152
  - `--service-summary-grid-gap-desktop` only applies at `768px` and above — the breakpoint
@@ -5,14 +5,17 @@
5
5
  <NuxtImg
6
6
  :src="serviceData.image"
7
7
  :alt="serviceData.title"
8
+ width="1080"
9
+ height="1080"
8
10
  :loading="imageLoading"
9
11
  :fetchpriority="imageFetchPriority"
10
12
  class="service-summary__image"
11
13
  />
12
14
  </div>
13
15
  <div class="service-summary__info-wrapper" :class="infoWrapperClasses">
14
- <EyebrowText font-size="large" :text-content="serviceData.subtitle" />
16
+ <EyebrowText v-if="serviceData.subtitle" font-size="large" :text-content="serviceData.subtitle" />
15
17
  <HeroText
18
+ v-if="serviceData.title"
16
19
  :id="headingId"
17
20
  :tag="headerTag"
18
21
  font-size="title"
@@ -20,12 +23,12 @@
20
23
  :style-class-passthrough="['mb-20']"
21
24
  />
22
25
 
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 v-if="serviceData.duration || serviceData.price" class="service-summary__pills">
27
+ <DisplayPill v-if="serviceData.duration" :label="serviceData.duration" size="md" variant="neutral" />
28
+ <DisplayPill v-if="serviceData.price" :label="priceLabel" size="md" variant="neutral" />
26
29
  </div>
27
30
 
28
- <p class="page-body-normal">{{ serviceData.whatIsIt }}</p>
31
+ <p v-if="serviceData.whatIsIt" class="page-body-normal">{{ serviceData.whatIsIt }}</p>
29
32
 
30
33
  <slot name="summary-link" :service-data="serviceData"></slot>
31
34
  </div>
@@ -43,6 +46,8 @@ interface Props {
43
46
  serviceData: Service;
44
47
  alignment?: "start" | "center" | "end";
45
48
  reverse?: boolean;
49
+ /** Text before the price in its pill, e.g. "From £95". Override for localisation; "" shows the price alone. */
50
+ pricePrefix?: string;
46
51
  styleClassPassthrough?: string | string[];
47
52
  }
48
53
  const props = withDefaults(defineProps<Props>(), {
@@ -51,10 +56,14 @@ const props = withDefaults(defineProps<Props>(), {
51
56
  index: 0,
52
57
  alignment: "center",
53
58
  reverse: false,
59
+ pricePrefix: "From",
54
60
  styleClassPassthrough: () => [],
55
61
  });
56
62
 
57
- const { headingId, ariaLabelledby } = useAriaLabelledById(() => props.tag);
63
+ // No title means no heading to point at, so don't label the landmark from one.
64
+ const { headingId, ariaLabelledby } = useAriaLabelledById(() => (props.serviceData.title ? props.tag : "div"));
65
+
66
+ const priceLabel = computed(() => `${props.pricePrefix} ${props.serviceData.price}`.trim());
58
67
 
59
68
  const infoWrapperClasses = computed(() => ({
60
69
  [`service-summary__info-wrapper--align-${props.alignment}`]: true,
@@ -98,14 +107,17 @@ watch(
98
107
  --_grid-gap: var(--service-summary-grid-gap, 2rem);
99
108
 
100
109
  display: grid;
101
- grid-template-columns: 1fr;
110
+ grid-template-columns: minmax(0, 1fr);
102
111
  gap: var(--_grid-gap);
103
112
  height: 100%;
104
113
 
105
114
  @container service-summary (width >= 768px) {
106
115
  --_grid-gap: var(--service-summary-grid-gap-desktop, 3rem);
107
116
 
108
- grid-template-columns: repeat(auto-fit, minmax(246px, 1fr));
117
+ grid-template-columns: repeat(
118
+ auto-fit,
119
+ minmax(min(var(--service-summary-column-min-width, 246px), 100%), 1fr)
120
+ );
109
121
 
110
122
  &.service-summary__grid--reverse {
111
123
  .service-summary__image-wrapper {
@@ -117,6 +129,17 @@ watch(
117
129
 
118
130
  .service-summary__info-wrapper {
119
131
  display: grid;
132
+ grid-template-columns: minmax(0, 1fr);
133
+ min-inline-size: 0;
134
+ overflow-wrap: anywhere;
135
+
136
+ .page-body-normal {
137
+ display: -webkit-box;
138
+ -webkit-box-orient: vertical;
139
+ overflow: hidden;
140
+ -webkit-line-clamp: var(--service-summary-body-line-clamp, none);
141
+ line-clamp: var(--service-summary-body-line-clamp, none);
142
+ }
120
143
 
121
144
  &.service-summary__info-wrapper--align-start {
122
145
  align-content: start;
@@ -171,6 +194,7 @@ watch(
171
194
  display: flex;
172
195
  flex-wrap: wrap;
173
196
  gap: var(--service-summary-pills-gap, 0.8rem);
197
+ min-inline-size: 0;
174
198
  margin-block-end: var(--service-summary-pills-margin-block-end, 2rem);
175
199
 
176
200
  --display-pill-background: var(--service-summary-pill-bg, transparent);
@@ -1,10 +1,13 @@
1
+ import { computed, ref } from "vue";
1
2
  import ServiceSummary from "../ServiceSummary.vue";
3
+ import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
4
  import PageRow from "../../../../01.atoms/page-row/PageRow.vue";
3
5
  import TextBlock from "../../../../01.atoms/text-block/TextBlock.vue";
4
6
  import EyebrowText from "../../../../01.atoms/text-blocks/eyebrow-text/EyebrowText.vue";
5
7
  import HeroText from "../../../../01.atoms/text-blocks/hero-text/HeroText.vue";
6
8
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
7
9
  import type { Service } from "~/types/types.services";
10
+ import type { MediaCanvas } from "~/types/components";
8
11
 
9
12
  const meta: Meta<typeof ServiceSummary> = {
10
13
  title: "Organisms/Services/Service Summary",
@@ -29,6 +32,10 @@ const meta: Meta<typeof ServiceSummary> = {
29
32
  control: { type: "boolean" },
30
33
  description: "Swap image and content columns",
31
34
  },
35
+ pricePrefix: {
36
+ control: { type: "text" },
37
+ description: "Text before the price in its pill; override for localisation, empty shows the price alone",
38
+ },
32
39
  styleClassPassthrough: {
33
40
  control: "object",
34
41
  description: "Additional CSS classes applied to the root element",
@@ -39,6 +46,7 @@ const meta: Meta<typeof ServiceSummary> = {
39
46
  headerTag: "h2",
40
47
  alignment: "center",
41
48
  reverse: false,
49
+ pricePrefix: "From",
42
50
  styleClassPassthrough: [],
43
51
  },
44
52
  parameters: {
@@ -297,3 +305,142 @@ export const RealisticPageContext: Story = {
297
305
  },
298
306
  },
299
307
  };
308
+
309
+ // ─── Stress test ──────────────────────────────────────────────────────────────
310
+
311
+ type LineClamp = "none" | "1" | "2" | "3" | "4";
312
+
313
+ // Story-only arg: drives a CSS token, not a ServiceSummary prop. See the argTypes entry.
314
+ type StressArgs = InstanceType<typeof ServiceSummary>["$props"] & { bodyLineClamp: LineClamp };
315
+
316
+ const longGerman = "Haarverlängerungsbehandlungsberatungsterminvereinbarung";
317
+
318
+ const stressSummaries: Service[] = [
319
+ {
320
+ ...sampleService,
321
+ slug: "long-german",
322
+ subtitle: `Freihändige ${longGerman}`,
323
+ title: `${longGerman} und ${longGerman}`,
324
+ duration: "Ungefähr zweieinhalb bis dreieinhalb Stunden einschließlich ausführlicher Beratung",
325
+ price: "95,00 € zuzüglich Pflegeprodukte nach individueller Absprache mit Ihrer Stylistin",
326
+ whatIsIt: "Eine ausgesprochen lange Beschreibung, wie sie ein CMS liefert, wenn niemand die Zeichenanzahl prüft. ".repeat(8),
327
+ },
328
+ {
329
+ ...sampleService,
330
+ slug: "emoji-html",
331
+ subtitle: "✨💇‍♀️ Colour",
332
+ title: "<script>alert('xss')</script>",
333
+ duration: "⏱",
334
+ price: "£∞",
335
+ whatIsIt: "🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈 <b>not bold</b> https://example.com/a/very/long/url/that/never/breaks/at/all",
336
+ },
337
+ {
338
+ ...sampleService,
339
+ slug: "rtl",
340
+ subtitle: "تلوين الشعر",
341
+ title: "تسريحات شعر جديدة لموسم الخريف",
342
+ duration: "ساعتان",
343
+ price: "١٢٠ جنيه",
344
+ whatIsIt: "وصف طويل باللغة العربية يمتد عبر عدة أسطر للتحقق من أن النص من اليمين إلى اليسار يعرض بشكل صحيح.",
345
+ },
346
+ {
347
+ ...sampleService,
348
+ slug: "empty",
349
+ image: "https://example.invalid/missing.jpg",
350
+ subtitle: "",
351
+ title: "",
352
+ duration: "",
353
+ price: "",
354
+ whatIsIt: "",
355
+ },
356
+ {
357
+ ...sampleService,
358
+ slug: "single-chars",
359
+ subtitle: "A",
360
+ title: "B",
361
+ duration: "1",
362
+ price: "0",
363
+ whatIsIt: "C",
364
+ },
365
+ ];
366
+
367
+ export const StressTest: StoryObj<StressArgs> = {
368
+ name: "Stress Test (Worst-Case Data)",
369
+ argTypes: {
370
+ bodyLineClamp: {
371
+ control: "select",
372
+ options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
373
+ description:
374
+ "**Story control, not a prop.** Sets the `--service-summary-body-line-clamp` CSS token on a wrapper so you can try it here. " +
375
+ "To use it in an app, set the token in your own CSS, e.g. `.services-page { --service-summary-body-line-clamp: 4; }`. " +
376
+ "`1` is single-line ellipsis, `none` (the default) shows everything.",
377
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
378
+ },
379
+ },
380
+ args: {
381
+ pricePrefix: "Ab einem Mindestpreis von",
382
+ bodyLineClamp: "none",
383
+ },
384
+ render: (args) => ({
385
+ components: { ServiceSummary },
386
+ setup() {
387
+ const componentArgs = computed(() => {
388
+ const { bodyLineClamp: _bodyLineClamp, ...rest } = args;
389
+ return rest;
390
+ });
391
+ const tokenStyles = computed(() => ({ "--service-summary-body-line-clamp": args.bodyLineClamp }));
392
+ return { componentArgs, tokenStyles, stressSummaries };
393
+ },
394
+ template: `
395
+ <div :style="tokenStyles" style="display:grid; gap:4rem;">
396
+ <ServiceSummary
397
+ v-for="(service, index) in stressSummaries"
398
+ :key="service.slug"
399
+ v-bind="componentArgs"
400
+ :index="index"
401
+ :reverse="index % 2 === 1"
402
+ :service-data="service"
403
+ >
404
+ <template #summary-link="{ serviceData }">
405
+ <a :href="'/services/' + serviceData.slug" style="color:inherit;">
406
+ Mehr über {{ serviceData.title || "diese Behandlung" }} erfahren →
407
+ </a>
408
+ </template>
409
+ </ServiceSummary>
410
+ </div>
411
+ `,
412
+ }),
413
+ decorators: [
414
+ (story, context) => ({
415
+ components: { story, CanvasSwitcher },
416
+ setup() {
417
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
418
+ return { canvasName };
419
+ },
420
+ template: `
421
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
422
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
423
+ </div>
424
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
425
+ <story />
426
+ </div>
427
+ `,
428
+ }),
429
+ ],
430
+ parameters: {
431
+ layout: "fullscreen",
432
+ initialCanvas: "mobileCanvas",
433
+ docs: {
434
+ description: {
435
+ story:
436
+ "Deliberately hostile data to find breakage: long German copy and unbroken compound words in every field, a long " +
437
+ "German price prefix, a very long duration and price (their pills cap at the column width and end in an ellipsis), " +
438
+ "emoji, HTML-like text (must render as text), an unbroken URL, right-to-left Arabic, a broken image, single characters, " +
439
+ "and a summary with every text field empty (no empty eyebrow, heading, pills or body is rendered, and a section root " +
440
+ "drops its aria-labelledby). Check at every canvas width, in both column orders: no text runs out of its column, " +
441
+ "the two columns stay balanced and stack on narrow canvases. Try the Body line clamp control too: it's a CSS token " +
442
+ "set by the story, not a prop.",
443
+ },
444
+ },
445
+ },
446
+ };
@@ -207,4 +207,30 @@ describe("ServiceSummary", () => {
207
207
  expect(wrapper.classes()).not.toContain("original");
208
208
  expect(wrapper.classes()).toContain("updated");
209
209
  });
210
+
211
+ // ─── Worst-case data ───────────────────────────────────────────────────
212
+
213
+ it("renders no empty eyebrow, heading, pills or body for empty strings", async () => {
214
+ const wrapper = await mountSuspended(ServiceSummary, {
215
+ props: {
216
+ serviceData: { ...mockService, subtitle: "", title: "", duration: "", price: "", whatIsIt: "" },
217
+ tag: "section",
218
+ },
219
+ });
220
+ expect(wrapper.find(".eyebrow-text").exists()).toBe(false);
221
+ expect(wrapper.find(".hero-text").exists()).toBe(false);
222
+ expect(wrapper.find(".service-summary__pills").exists()).toBe(false);
223
+ expect(wrapper.find("p.page-body-normal").exists()).toBe(false);
224
+ expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
225
+ });
226
+
227
+ it("uses pricePrefix for the price pill, and shows the price alone when it is empty", async () => {
228
+ const wrapper = await mountSuspended(ServiceSummary, {
229
+ props: { serviceData: mockService, pricePrefix: "Ab" },
230
+ });
231
+ expect(wrapper.text()).toContain(`Ab ${mockService.price}`);
232
+ await wrapper.setProps({ pricePrefix: "" });
233
+ const labels = wrapper.findAll(".display-pill-label").map((label) => label.element.textContent);
234
+ expect(labels).toContain(mockService.price);
235
+ });
210
236
  });