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,6 +1,9 @@
1
+ import { computed, ref } from "vue";
1
2
  import ServicesCard from "../ServicesCard.vue";
3
+ import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
5
  import type { Service } from "~/types/types.services";
6
+ import type { MediaCanvas } from "~/types/components";
4
7
 
5
8
  const meta: Meta<typeof ServicesCard> = {
6
9
  title: "Organisms/Services/Services Card",
@@ -47,12 +50,6 @@ const meta: Meta<typeof ServicesCard> = {
47
50
  styleClassPassthrough: [],
48
51
  },
49
52
  parameters: {
50
- backgrounds: {
51
- default: "storybook-canvas",
52
- options: {
53
- "storybook-canvas": { name: "Canvas", value: "oklch(0.163 0.005 17)" },
54
- },
55
- },
56
53
  docs: {
57
54
  description: {
58
55
  component:
@@ -329,32 +326,32 @@ export const DarkOverlayCards: Story = {
329
326
  --services-card-border-colour-hover: #6b5a4e;
330
327
  --services-card-transform-hover: translateY(-0.4rem);
331
328
 
332
- --image-wrapper-border-radius: 0;
333
- --image-wrapper-border-image-zoom-transform: none;
334
- --image-wrapper-details-scrim-colour: #141110;
335
- --image-wrapper-details-text-colour: #f3ece4;
336
- --image-wrapper-details-eyebrow-text-colour: #c9a24a;
337
- --image-wrapper-details-gap: 0.4rem;
329
+ --services-card-image-border-radius: 0;
330
+ --services-card-image-zoom-transform: none;
331
+ --services-card-image-details-scrim-colour: #141110;
332
+ --services-card-image-details-text-colour: #f3ece4;
333
+ --services-card-image-details-eyebrow-colour: #c9a24a;
334
+ --services-card-image-details-gap: 0.4rem;
338
335
  --eyebrow-text-font-family: "Poppins", sans-serif;
339
336
  --eyebrow-text-font-style: normal;
340
337
  --eyebrow-text-font-weight: 600;
341
338
  --eyebrow-text-letter-spacing: 0.25em;
342
- --image-wrapper-details-eyebrow-text-padding-inline: 3.6rem;
343
- --image-wrapper-details-hero-text-padding-block: 0 2.8rem;
344
- --image-wrapper-details-hero-text-padding-inline: 3.6rem;
339
+ --services-card-image-details-eyebrow-padding-inline: 3.6rem;
340
+ --services-card-image-details-title-padding-block: 0 2.8rem;
341
+ --services-card-image-details-title-padding-inline: 3.6rem;
345
342
 
346
- --details-wrapper-padding-block: 3.6rem;
347
- --details-wrapper-padding-inline: 3.6rem;
348
- --description-text-colour: #a39a94;
349
- --description-line-height: 1.75;
350
- --description-font-weight: 300;
351
- --meta-border-colour: #2e2826;
352
- --meta-padding-block: 2.4rem 0;
353
- --meta-text-colour: #8a817b;
354
- --meta-duration-font-weight: 400;
355
- --meta-price-font-weight: 400;
356
- --meta-letter-spacing: 0.15em;
357
- --meta-price-text-colour: #e8dfd6;
343
+ --services-card-details-padding-block: 3.6rem;
344
+ --services-card-details-padding-inline: 3.6rem;
345
+ --services-card-description-text-colour: #a39a94;
346
+ --services-card-description-line-height: 1.75;
347
+ --services-card-description-font-weight: 300;
348
+ --services-card-meta-border-colour: #2e2826;
349
+ --services-card-meta-padding-block: 2.4rem 0;
350
+ --services-card-meta-text-colour: #8a817b;
351
+ --services-card-meta-duration-font-weight: 400;
352
+ --services-card-meta-price-font-weight: 400;
353
+ --services-card-meta-letter-spacing: 0.15em;
354
+ --services-card-meta-price-text-colour: #e8dfd6;
358
355
  }
359
356
  </component>
360
357
  <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(min(26rem, 100%), 1fr)); gap:4rem; padding:2.4rem; background:#121010;">
@@ -379,3 +376,173 @@ export const DarkOverlayCards: Story = {
379
376
  },
380
377
  },
381
378
  };
379
+
380
+ // ─── Stress test ──────────────────────────────────────────────────────────────
381
+
382
+ type LineClamp = "none" | "1" | "2" | "3" | "4";
383
+
384
+ // Story-only arg: drives a CSS token, not a ServicesCard prop. See the argTypes entry.
385
+ type StressArgs = InstanceType<typeof ServicesCard>["$props"] & { descriptionLineClamp: LineClamp };
386
+
387
+ const longGerman = "Haarverlängerungsbehandlungsberatungsterminvereinbarungsbestätigungsschreiben";
388
+ const longUrl =
389
+ "https://example.com/services/a-very-long-path-with-no-spaces-at-all?utm_source=newsletter&utm_campaign=autumn-2026";
390
+
391
+ const stressService = (overrides: Partial<Service>): Service => ({ ...balayageService, ...overrides });
392
+
393
+ const stressServices: { service: Service; titlesWithinImageWrapper?: boolean; href?: string; action?: string }[] = [
394
+ {
395
+ service: stressService({
396
+ slug: "long-german",
397
+ subtitle: `Freihändige ${longGerman}`,
398
+ title: longGerman,
399
+ shortDescription:
400
+ "Eine ausgesprochen lange Beschreibung, wie sie ein CMS liefert, wenn niemand die Zeichenanzahl prüft. ".repeat(6),
401
+ duration: "Ungefähr zweieinhalb bis dreieinhalb Stunden einschließlich Beratung",
402
+ price: "Ab 95,00 € zuzüglich Pflegeprodukte nach individueller Absprache",
403
+ }),
404
+ },
405
+ {
406
+ service: stressService({
407
+ slug: "emoji-html",
408
+ subtitle: "✨💇‍♀️ Colour",
409
+ title: "<script>alert('xss')</script>",
410
+ shortDescription: "🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈 <b>not bold</b>",
411
+ duration: "⏱",
412
+ price: "£∞",
413
+ }),
414
+ href: longUrl,
415
+ },
416
+ {
417
+ service: stressService({
418
+ slug: "rtl",
419
+ subtitle: "تلوين الشعر",
420
+ title: "تسريحات شعر جديدة لموسم الخريف",
421
+ shortDescription: "وصف طويل باللغة العربية يمتد عبر عدة أسطر للتحقق من أن النص من اليمين إلى اليسار يعرض بشكل صحيح.",
422
+ duration: "ساعتان",
423
+ price: "١٢٠ جنيه",
424
+ }),
425
+ },
426
+ {
427
+ service: stressService({
428
+ slug: "empty",
429
+ subtitle: "",
430
+ title: "",
431
+ shortDescription: "",
432
+ duration: "",
433
+ price: "",
434
+ }),
435
+ },
436
+ {
437
+ service: stressService({
438
+ slug: "broken-image",
439
+ image: "https://example.invalid/missing.jpg",
440
+ subtitle: "A",
441
+ title: "B",
442
+ shortDescription: "C",
443
+ duration: "1",
444
+ price: "£0",
445
+ }),
446
+ },
447
+ {
448
+ service: stressService({
449
+ slug: "overlay-long",
450
+ image: "https://picsum.photos/seed/stress-overlay/600/800",
451
+ subtitle: longUrl,
452
+ title: `${longGerman} ${longGerman}`,
453
+ shortDescription: longUrl,
454
+ }),
455
+ titlesWithinImageWrapper: true,
456
+ href: "#overlay-long",
457
+ },
458
+ {
459
+ service: stressService({ slug: "long-action", title: "Long button label" }),
460
+ action: `Jetzt ${longGerman} buchen`,
461
+ },
462
+ ];
463
+
464
+ export const StressTest: StoryObj<StressArgs> = {
465
+ name: "Stress Test (Worst-Case Data)",
466
+ argTypes: {
467
+ descriptionLineClamp: {
468
+ control: "select",
469
+ options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
470
+ description:
471
+ "**Story control, not a prop.** Sets the `--services-card-description-line-clamp` CSS token on a wrapper so you can try it here. " +
472
+ "To use it in an app, set the token in your own CSS, e.g. `.services-list { --services-card-description-line-clamp: 3; }`. " +
473
+ "`1` is single-line ellipsis, `none` (the default) shows everything.",
474
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
475
+ },
476
+ },
477
+ args: {
478
+ descriptionLineClamp: "none",
479
+ },
480
+ render: (args) => ({
481
+ components: { ServicesCard },
482
+ setup() {
483
+ const componentArgs = computed(() => {
484
+ const { descriptionLineClamp: _descriptionLineClamp, ...rest } = args;
485
+ return rest;
486
+ });
487
+ const tokenStyles = computed(() => ({
488
+ "--services-card-description-line-clamp": args.descriptionLineClamp,
489
+ }));
490
+ return { componentArgs, tokenStyles, stressServices };
491
+ },
492
+ template: `
493
+ <div
494
+ :style="tokenStyles"
495
+ style="display:grid; grid-template-columns:repeat(auto-fit, minmax(min(25rem, 100%), 1fr)); gap:3.2rem;"
496
+ >
497
+ <ServicesCard
498
+ v-for="item in stressServices"
499
+ :key="item.service.slug"
500
+ v-bind="componentArgs"
501
+ :service-data="item.service"
502
+ :href="item.href"
503
+ :titles-within-image-wrapper="item.titlesWithinImageWrapper"
504
+ >
505
+ <template v-if="item.action" #actions>
506
+ <a href="#long-action" style="display:inline-block; padding:1.2rem 2.4rem; background:#333; color:#fff; border-radius:0.4rem; text-decoration:none;">
507
+ {{ item.action }}
508
+ </a>
509
+ </template>
510
+ </ServicesCard>
511
+ </div>
512
+ `,
513
+ }),
514
+ decorators: [
515
+ (story, context) => ({
516
+ components: { story, CanvasSwitcher },
517
+ setup() {
518
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
519
+ return { canvasName };
520
+ },
521
+ template: `
522
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
523
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
524
+ </div>
525
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
526
+ <story />
527
+ </div>
528
+ `,
529
+ }),
530
+ ],
531
+ parameters: {
532
+ layout: "fullscreen",
533
+ initialCanvas: "mobileCanvas",
534
+ docs: {
535
+ description: {
536
+ story:
537
+ "Deliberately hostile data to find breakage, in a grid like ServicesCardGrid's: a long German compound word as title, " +
538
+ "eyebrow and actions label, a long paragraph description, long duration and price strings, emoji, HTML-like text " +
539
+ "(must render as text), right-to-left Arabic, a card with every text field empty (no empty eyebrow, heading, " +
540
+ "description or meta row is rendered), a broken image URL (its alt text shows), single characters, a whole-card " +
541
+ "link to a long URL, and long unbroken titles over the image. Check at every canvas width: no text runs out of its " +
542
+ "card, every column stays the same width, a long duration/price wraps onto two lines rather than overflowing, and " +
543
+ "the meta row and actions stay pinned to the bottom. Try the Description line clamp control too: it's a CSS token " +
544
+ "set by the story, not a prop.",
545
+ },
546
+ },
547
+ },
548
+ };
@@ -90,14 +90,14 @@ describe("ServicesCard", () => {
90
90
  const wrapper = await mountSuspended(ServicesCard, {
91
91
  props: { serviceData: mockService },
92
92
  });
93
- expect(wrapper.find(".description").text()).toBe(mockService.shortDescription);
93
+ expect(wrapper.find(".services-card-description").text()).toBe(mockService.shortDescription);
94
94
  });
95
95
 
96
96
  it("passes image src and alt to the image element", async () => {
97
97
  const wrapper = await mountSuspended(ServicesCard, {
98
98
  props: { serviceData: mockService },
99
99
  });
100
- const img = wrapper.find(".image-wrapper img");
100
+ const img = wrapper.find(".services-card-image-wrapper img");
101
101
  expect(img.attributes("src")).toContain(mockService.image);
102
102
  expect(img.attributes("alt")).toBe(mockService.title);
103
103
  });
@@ -106,7 +106,7 @@ describe("ServicesCard", () => {
106
106
  const wrapper = await mountSuspended(ServicesCard, {
107
107
  props: { serviceData: mockService },
108
108
  });
109
- expect(wrapper.find(".image-wrapper img").attributes("loading")).toBe("lazy");
109
+ expect(wrapper.find(".services-card-image-wrapper img").attributes("loading")).toBe("lazy");
110
110
  });
111
111
 
112
112
  // ─── Meta row (duration / price) ───────────────────────────────────────
@@ -115,16 +115,16 @@ describe("ServicesCard", () => {
115
115
  const wrapper = await mountSuspended(ServicesCard, {
116
116
  props: { serviceData: mockService },
117
117
  });
118
- expect(wrapper.find(".meta-duration").text()).toBe(mockService.duration);
119
- expect(wrapper.find(".meta-price").text()).toBe(mockService.price);
118
+ expect(wrapper.find(".services-card-meta-duration").text()).toBe(mockService.duration);
119
+ expect(wrapper.find(".services-card-meta-price").text()).toBe(mockService.price);
120
120
  });
121
121
 
122
122
  it("overrides duration/price text with durationText/priceText props", async () => {
123
123
  const wrapper = await mountSuspended(ServicesCard, {
124
124
  props: { serviceData: mockService, durationText: "1 hour", priceText: "£75" },
125
125
  });
126
- expect(wrapper.find(".meta-duration").text()).toBe("1 hour");
127
- expect(wrapper.find(".meta-price").text()).toBe("£75");
126
+ expect(wrapper.find(".services-card-meta-duration").text()).toBe("1 hour");
127
+ expect(wrapper.find(".services-card-meta-price").text()).toBe("£75");
128
128
  });
129
129
 
130
130
  it("overrides duration/price content with duration/price slots", async () => {
@@ -144,7 +144,7 @@ describe("ServicesCard", () => {
144
144
  const wrapper = await mountSuspended(ServicesCard, {
145
145
  props: { serviceData: serviceWithoutMeta },
146
146
  });
147
- expect(wrapper.find(".meta").exists()).toBe(false);
147
+ expect(wrapper.find(".services-card-meta").exists()).toBe(false);
148
148
  });
149
149
 
150
150
  // ─── Actions slot ───────────────────────────────────────────────────────
@@ -303,4 +303,30 @@ describe("ServicesCard", () => {
303
303
  expect(wrapper.find("a.is-clickable").exists()).toBe(false);
304
304
  expect(wrapper.find("button").exists()).toBe(true);
305
305
  });
306
+
307
+ // ─── Worst-case data ───────────────────────────────────────────────────
308
+
309
+ it("renders no empty eyebrow, heading or description for empty strings", async () => {
310
+ const wrapper = await mountSuspended(ServicesCard, {
311
+ props: { serviceData: { ...mockService, subtitle: "", title: "", shortDescription: "" } },
312
+ });
313
+ expect(wrapper.find(".eyebrow-text").exists()).toBe(false);
314
+ expect(wrapper.find(".hero-text").exists()).toBe(false);
315
+ expect(wrapper.find(".services-card-description").exists()).toBe(false);
316
+ });
317
+
318
+ it("drops the image overlay entirely when both titles are empty", async () => {
319
+ const wrapper = await mountSuspended(ServicesCard, {
320
+ props: { serviceData: { ...mockService, subtitle: "", title: "" }, titlesWithinImageWrapper: true },
321
+ });
322
+ expect(wrapper.find(".services-card-image-details").exists()).toBe(false);
323
+ });
324
+
325
+ it("renders HTML-like data as text", async () => {
326
+ const wrapper = await mountSuspended(ServicesCard, {
327
+ props: { serviceData: { ...mockService, title: "<b>bold</b>" } },
328
+ });
329
+ expect(wrapper.find(".hero-text").text()).toBe("<b>bold</b>");
330
+ expect(wrapper.find(".hero-text b").exists()).toBe(false);
331
+ });
306
332
  });
@@ -2,20 +2,20 @@
2
2
 
3
3
  exports[`ServicesCard > renders correct HTML structure 1`] = `
4
4
  "<div class="services-card">
5
- <div class="grid-stack image-wrapper">
6
- <div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/test.jpg 1x, /_ipx/_/images/test.jpg 2x" alt="Test Service" loading="lazy" class="image" src="/_ipx/_/images/test.jpg"></div>
5
+ <div class="grid-stack services-card-image-wrapper">
6
+ <div class="grid-stack__layer"><img width="640" height="853" data-nuxt-img="" srcset="/_ipx/s_640x853/images/test.jpg 1x, /_ipx/s_1280x1706/images/test.jpg 2x" alt="Test Service" loading="lazy" class="services-card-image" src="/_ipx/s_640x853/images/test.jpg"></div>
7
7
  <div class="grid-stack__layer"></div>
8
8
  </div>
9
- <div class="details-wrapper">
9
+ <div class="services-card-details">
10
10
  <div class="eyebrow-text large">A subtitle</div>
11
11
  <h2 class="hero-text heading axis-horizontal">
12
12
  <!--v-if--><span class="text-block-0 normal">Test Service</span>
13
13
  </h2>
14
- <div class="description">A short description of the service.</div>
15
- <div class="footer">
16
- <div class="meta">
17
- <div class="meta-duration">60 mins</div>
18
- <div class="meta-price">£50</div>
14
+ <div class="services-card-description">A short description of the service.</div>
15
+ <div class="services-card-footer">
16
+ <div class="services-card-meta">
17
+ <div class="services-card-meta-duration">60 mins</div>
18
+ <div class="services-card-meta-price">£50</div>
19
19
  </div>
20
20
  </div>
21
21
  </div>
@@ -14,12 +14,17 @@
14
14
  |---|---|---|
15
15
  | `--services-card-grid-column-min-width` | `250px` | Minimum card column width before wrapping (capped at the container width) |
16
16
  | `--services-card-grid-gap` | `4rem` | Gap between cards |
17
+ | `--services-card-grid-button-text-white-space` | `normal` | Wrapping of each card CTA label (`InputButton`'s `--input-button-text-white-space`). Set `nowrap` for single-line labels |
17
18
 
18
19
  Each item is a full `ServiceSummary` or `ServicesCard`, styled by its own tokens (see
19
20
  `03.organisms/services/service-summary/CONSUMER-STYLING.md` and
20
21
  `03.organisms/services/services-card/CONSUMER-STYLING.md`). ServicesCardGrid's CTA is
21
22
  `InputButton` (`variant="secondary"`).
22
23
 
24
+ > Changed 2026-10-06: CTA labels wrap inside the card (they used to stay on one line and be clipped
25
+ > by the card edge for a long or translated title), and an empty title no longer leaves a trailing
26
+ > space in the label.
27
+
23
28
  > Changed 2026-09-28: ServicesCardGrid columns use `minmax(min(<min-width>, 100%), 1fr)`, so a
24
29
  > single column no longer overflows a container narrower than the minimum width.
25
30
 
@@ -42,6 +47,10 @@ Set the tokens above on an element you own (a page or section class, or a class
42
47
  `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
43
48
  `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
44
49
 
50
+ **Caveat:** `.services-card-grid` re-declares `--input-button-text-white-space` on itself, so setting
51
+ that InputButton token on an ancestor doesn't reach the grid's buttons. Use
52
+ `--services-card-grid-button-text-white-space` instead.
53
+
45
54
  ---
46
55
 
47
56
  ## Class passthrough
@@ -10,7 +10,7 @@
10
10
  <template #actions="{ serviceData }">
11
11
  <InputButton
12
12
  variant="secondary"
13
- :button-text="`${buttonTextPrefix} ${serviceData.title}`"
13
+ :button-text="`${buttonTextPrefix} ${serviceData.title}`.trim()"
14
14
  :href="`${hrefBase}${serviceData.slug}`"
15
15
  :style-class-passthrough="['mbs-24']"
16
16
  >
@@ -64,6 +64,7 @@ watch(
64
64
  display: grid;
65
65
  grid-template-columns: repeat(auto-fit, minmax(min(var(--services-card-grid-column-min-width, 250px), 100%), 1fr));
66
66
  gap: var(--services-card-grid-gap, 4rem);
67
+ --input-button-text-white-space: var(--services-card-grid-button-text-white-space, normal);
67
68
  }
68
69
  }
69
70
  </style>
@@ -32,12 +32,6 @@ const meta: Meta<typeof ServiceSummaryGrid> = {
32
32
  styleClassPassthrough: [],
33
33
  },
34
34
  parameters: {
35
- backgrounds: {
36
- default: "storybook-canvas",
37
- options: {
38
- "storybook-canvas": { name: "Canvas", value: "oklch(0.163 0.005 17)" },
39
- },
40
- },
41
35
  docs: {
42
36
  description: {
43
37
  component:
@@ -201,3 +195,49 @@ export const EmptyData: Story = {
201
195
  },
202
196
  },
203
197
  };
198
+
199
+ // ─── Stress test ──────────────────────────────────────────────────────────────
200
+
201
+ const stressServices: Service[] = [
202
+ ...Array.from({ length: 7 }, (_, index) =>
203
+ makeService(`stress-${index}`, `Service ${index + 1}`, "Grid stress", `https://picsum.photos/seed/summary-stress-${index}/800/600`)
204
+ ),
205
+ makeService("stress-0", "Duplicate slug", "Same slug as the first row", "https://picsum.photos/seed/summary-stress-dup/800/600"),
206
+ ];
207
+
208
+ const longLinkLabel =
209
+ "Mehr über diese außergewöhnlich ausführliche Haarverlängerungsbehandlungsberatung erfahren und einen Termin vereinbaren";
210
+
211
+ export const StressTest: Story = {
212
+ name: "Stress Test (Worst-Case Data)",
213
+ args: {
214
+ useAlternateReverse: true,
215
+ },
216
+ render: (args) => ({
217
+ components: { ServiceSummaryGrid },
218
+ setup() {
219
+ return { args, stressServices, longLinkLabel };
220
+ },
221
+ template: `
222
+ <ServiceSummaryGrid v-bind="args" :services-data="stressServices">
223
+ <template #summary-link="{ serviceData }">
224
+ <a :href="'/services/' + serviceData.slug" style="display:inline-block;margin-top:1.6rem;color:inherit;overflow-wrap:anywhere;">
225
+ {{ longLinkLabel }}
226
+ </a>
227
+ </template>
228
+ </ServiceSummaryGrid>
229
+ `,
230
+ }),
231
+ parameters: {
232
+ docs: {
233
+ description: {
234
+ story:
235
+ "Grid-level worst case: eight rows (an odd count of distinct services, so alternate-reverse ends on a " +
236
+ "non-reversed row), two rows sharing a slug, and a long German summary-link label in every row. Check that " +
237
+ "rows alternate correctly all the way down, the duplicate slug renders both rows, and the slot content stays " +
238
+ "inside its column. Field-level worst-case data (long titles, empty fields, emoji) belongs to ServiceSummary's " +
239
+ "own stress story, since every field is rendered there.",
240
+ },
241
+ },
242
+ },
243
+ };
@@ -1,7 +1,9 @@
1
- import { computed } from "vue";
1
+ import { computed, ref } from "vue";
2
2
  import ServicesCardGrid from "../ServicesCardGrid.vue";
3
+ import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
3
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
4
5
  import type { Service } from "~/types/types.services";
6
+ import type { MediaCanvas } from "~/types/components";
5
7
 
6
8
  type StoryArgs = {
7
9
  tag?: "div" | "section" | "main";
@@ -57,7 +59,7 @@ const meta: Meta<StoryArgs> = {
57
59
  control: { type: "inline-radio" },
58
60
  options: [1, 2, 3, 4, 5],
59
61
  description:
60
- "Story-only control — sets ServicesCard's --description-line-clamp custom property on the grid wrapper (not a real component prop) to demo description clamping across cards",
62
+ "Story-only control — sets ServicesCard's --services-card-description-line-clamp custom property on the grid wrapper (not a real component prop) to demo description clamping across cards",
61
63
  },
62
64
  cardMinWidth: {
63
65
  control: { type: "inline-radio" },
@@ -78,12 +80,6 @@ const meta: Meta<StoryArgs> = {
78
80
  cardMinWidth: "250px",
79
81
  },
80
82
  parameters: {
81
- backgrounds: {
82
- default: "storybook-canvas",
83
- options: {
84
- "storybook-canvas": { name: "Canvas", value: "oklch(0.163 0.005 17)" },
85
- },
86
- },
87
83
  docs: {
88
84
  description: {
89
85
  component:
@@ -201,13 +197,13 @@ const hideDemoControls = {
201
197
 
202
198
  /**
203
199
  * lineClamp and cardMinWidth are story-only controls (not real ServicesCardGrid props).
204
- * They set --description-line-clamp (read by each ServicesCard's .description) and
200
+ * They set --services-card-description-line-clamp (read by each ServicesCard's .description) and
205
201
  * --services-card-grid-column-min-width (the grid's minmax() floor) on a wrapper div,
206
202
  * and are stripped from the args bound to the component so they don't leak as attributes.
207
203
  */
208
204
  function useStorySetup(args: StoryArgs) {
209
205
  const wrapperStyle = computed(() => ({
210
- "--description-line-clamp": String(args.lineClamp ?? 3),
206
+ "--services-card-description-line-clamp": String(args.lineClamp ?? 3),
211
207
  "--services-card-grid-column-min-width": args.cardMinWidth ?? "250px",
212
208
  }));
213
209
  const componentArgs = computed(() => {
@@ -360,7 +356,7 @@ export const DescriptionLineClamp: Story = {
360
356
  docs: {
361
357
  description: {
362
358
  story:
363
- "Six cards with progressively longer shortDescription text, all sharing one --description-line-clamp value set via the lineClamp control. Use the control to see how the same clamp value affects a one-line description (no visible clamping) versus a long paragraph (clamped with an ellipsis) — card heights stay equal since the meta row and actions slot sit below the clamped description rather than growing with it.",
359
+ "Six cards with progressively longer shortDescription text, all sharing one --services-card-description-line-clamp value set via the lineClamp control. Use the control to see how the same clamp value affects a one-line description (no visible clamping) versus a long paragraph (clamped with an ellipsis) — card heights stay equal since the meta row and actions slot sit below the clamped description rather than growing with it.",
364
360
  },
365
361
  },
366
362
  },
@@ -391,3 +387,85 @@ export const CardWidth: Story = {
391
387
  },
392
388
  },
393
389
  };
390
+
391
+ // ─── Stress test ──────────────────────────────────────────────────────────────
392
+
393
+ const longGerman = "Haarverlängerungsbehandlungsberatungsterminvereinbarung";
394
+
395
+ const stressServices: Service[] = [
396
+ {
397
+ ...makeService("long-german", longGerman, `Freihändige ${longGerman}`, "https://picsum.photos/seed/stress-grid-1/600/800"),
398
+ shortDescription: "Eine ausgesprochen lange Beschreibung, wie sie ein CMS liefert. ".repeat(6),
399
+ duration: "Ungefähr zweieinhalb bis dreieinhalb Stunden",
400
+ price: "Ab 95,00 € zuzüglich Pflegeprodukte",
401
+ },
402
+ { ...makeService("emoji", "✨💇‍♀️ <b>not bold</b>", "<script>alert(1)</script>", "https://picsum.photos/seed/stress-grid-2/600/800") },
403
+ {
404
+ ...makeService("rtl", "تسريحات شعر جديدة لموسم الخريف", "تلوين الشعر", "https://picsum.photos/seed/stress-grid-3/600/800"),
405
+ shortDescription: "وصف طويل باللغة العربية يمتد عبر عدة أسطر للتحقق من النص من اليمين إلى اليسار.",
406
+ },
407
+ {
408
+ ...makeService("", "", "", "https://example.invalid/missing.jpg"),
409
+ shortDescription: "",
410
+ duration: "",
411
+ price: "",
412
+ },
413
+ { ...makeService("a b/c?d=é#frag", "A", "B", "https://picsum.photos/seed/stress-grid-5/600/800"), shortDescription: "C" },
414
+ { ...makeService("long-german", "Duplicate slug", "Same slug as the first card", "https://picsum.photos/seed/stress-grid-6/600/800") },
415
+ ...Array.from({ length: 6 }, (_, index) =>
416
+ makeService(`filler-${index}`, `Filler ${index + 1}`, "Filler", `https://picsum.photos/seed/stress-grid-f${index}/600/800`)
417
+ ),
418
+ ];
419
+
420
+ export const StressTest: Story = {
421
+ name: "Stress Test (Worst-Case Data)",
422
+ args: {
423
+ buttonTextPrefix: "Unverbindliche Anfrage stellen zu",
424
+ hrefBase: "",
425
+ buttonIcon: "this-icon:does-not-exist",
426
+ },
427
+ render: (args) => ({
428
+ components: { ServicesCardGrid },
429
+ setup() {
430
+ return { ...useStorySetup(args), stressServices };
431
+ },
432
+ template: `
433
+ <div :style="wrapperStyle">
434
+ <ServicesCardGrid v-bind="componentArgs" :services-data="stressServices" />
435
+ </div>
436
+ `,
437
+ }),
438
+ decorators: [
439
+ (story, context) => ({
440
+ components: { story, CanvasSwitcher },
441
+ setup() {
442
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
443
+ return { canvasName };
444
+ },
445
+ template: `
446
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
447
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
448
+ </div>
449
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
450
+ <story />
451
+ </div>
452
+ `,
453
+ }),
454
+ ],
455
+ parameters: {
456
+ layout: "fullscreen",
457
+ initialCanvas: "mobileCanvas",
458
+ docs: {
459
+ description: {
460
+ story:
461
+ "Deliberately hostile data to find breakage: a long German title, so the CTA label (long German prefix + title) " +
462
+ "is far wider than the card; emoji and HTML-like text (must render as text); right-to-left Arabic; a card with " +
463
+ "every text field and the slug empty (label is just the prefix, no trailing space); a broken image; a slug with " +
464
+ "spaces and URL characters; two cards sharing a slug; 12 cards; an empty hrefBase and a broken icon name. " +
465
+ "Check at every canvas width: every CTA label wraps inside its card instead of being clipped at the edge, all " +
466
+ "columns stay equal, and the meta rows and buttons line up along each row. The line clamp and card width " +
467
+ "controls are CSS tokens set by the story, not props.",
468
+ },
469
+ },
470
+ },
471
+ };
@@ -196,4 +196,12 @@ describe("ServicesCardGrid button icon", () => {
196
196
  });
197
197
  expect(iconName(wrapper)).toContain("lucide:arrow-right");
198
198
  });
199
+
200
+ it("drops the trailing space from the CTA label when a title is empty", async () => {
201
+ const wrapper = await mountSuspended(ServicesCardGrid, {
202
+ props: { servicesData: [makeService("untitled", "")] },
203
+ });
204
+ expect(wrapper.find(".button-text").text()).toBe("Enquire about");
205
+ expect(wrapper.find(".button-text").element.textContent).toBe("Enquire about");
206
+ });
199
207
  });