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,11 +1,54 @@
1
+ import { computed, ref } from "vue";
1
2
  import SliderGallery from "../SliderGallery.vue";
3
+ import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
- import type { IGalleryData } from "~/types/components";
5
+ import type { IGalleryData, MediaCanvas } from "~/types/components";
4
6
 
5
- const meta: Meta<typeof SliderGallery> = {
7
+ type LineClamp = "none" | "1" | "2" | "3" | "4";
8
+
9
+ // Story-only args: each drives a CSS token, not a SliderGallery prop. See the argTypes entries.
10
+ const lineClampTokens = {
11
+ authorLineClamp: { token: "--slider-gallery-author-line-clamp", label: "Author (stylist)", fallback: "none" },
12
+ titleLineClamp: { token: "--slider-gallery-title-line-clamp", label: "Title", fallback: "none" },
13
+ topicLineClamp: { token: "--slider-gallery-topic-line-clamp", label: "Topic (category)", fallback: "none" },
14
+ descriptionLineClamp: { token: "--slider-gallery-description-line-clamp", label: "Description", fallback: "none" },
15
+ thumbnailTitleLineClamp: { token: "--slider-gallery-thumbnail-title-line-clamp", label: "Thumbnail title", fallback: "2" },
16
+ thumbnailDescriptionLineClamp: {
17
+ token: "--slider-gallery-thumbnail-description-line-clamp",
18
+ label: "Thumbnail description",
19
+ fallback: "2",
20
+ },
21
+ } as const;
22
+
23
+ type LineClampArg = keyof typeof lineClampTokens;
24
+
25
+ type StoryArgs = InstanceType<typeof SliderGallery>["$props"] & Record<LineClampArg, LineClamp>;
26
+
27
+ const lineClampArgTypes = Object.fromEntries(
28
+ Object.entries(lineClampTokens).map(([arg, { token, label, fallback }]) => [
29
+ arg,
30
+ {
31
+ name: `${label} line clamp`,
32
+ control: "select",
33
+ options: ["none", "1", "2", "3", "4"] satisfies LineClamp[],
34
+ description:
35
+ `**Story control, not a prop.** Sets the \`${token}\` CSS token on a wrapper so you can try it here. ` +
36
+ `To use it in an app, set the token in your own CSS, e.g. \`.hero { ${token}: 2; }\`. ` +
37
+ `\`1\` is single-line ellipsis, \`none\` shows everything.`,
38
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: fallback } },
39
+ },
40
+ ])
41
+ );
42
+
43
+ const lineClampArgs = Object.fromEntries(
44
+ Object.entries(lineClampTokens).map(([arg, { fallback }]) => [arg, fallback])
45
+ ) as Record<LineClampArg, LineClamp>;
46
+
47
+ const meta: Meta<StoryArgs> = {
6
48
  title: "Organisms/Image Galleries/Slider Gallery",
7
49
  component: SliderGallery,
8
50
  argTypes: {
51
+ ...lineClampArgTypes,
9
52
  autoRun: {
10
53
  control: { type: "boolean" },
11
54
  description: "Automatically advance to the next slide",
@@ -40,6 +83,7 @@ const meta: Meta<typeof SliderGallery> = {
40
83
  },
41
84
  },
42
85
  args: {
86
+ ...lineClampArgs,
43
87
  autoRun: true,
44
88
  autoRunInterval: 7000,
45
89
  animationDuration: 3000,
@@ -64,7 +108,7 @@ const meta: Meta<typeof SliderGallery> = {
64
108
  };
65
109
 
66
110
  export default meta;
67
- type Story = StoryObj<typeof SliderGallery>;
111
+ type Story = StoryObj<StoryArgs>;
68
112
 
69
113
  // ─── Fixtures ─────────────────────────────────────────────────────────────────
70
114
 
@@ -123,17 +167,105 @@ const sampleSlides: IGalleryData[] = [
123
167
  },
124
168
  ];
125
169
 
170
+ const longGerman =
171
+ "Donaudampfschifffahrtsgesellschaftskapitänsmützenbandherstellungsbetriebsgenossenschaftsvorsitzender";
172
+ const longUrl =
173
+ "https://example.com/a/very/long/path/that/never/breaks/because/it/has/no/spaces/at/all?utm_source=newsletter&utm_medium=email&utm_campaign=autumn-collection-2026-final-final-v3";
174
+ const longParagraph =
175
+ "Eine ausgesprochen lange Beschreibung, wie sie ein CMS liefert, wenn niemand die Zeichenanzahl prüft. ".repeat(8);
176
+
177
+ const stressSlides: IGalleryData[] = [
178
+ {
179
+ src: "https://picsum.photos/seed/stress1/1920/1080",
180
+ alt: "Long German copy in every field",
181
+ stylist: `STYLISTIN ${longGerman.toUpperCase()}`,
182
+ title: `${longGerman} und Freunde`,
183
+ category: "Haarverlängerungen, Strähnchen und vollständige Farbkorrekturen",
184
+ description: longParagraph,
185
+ thumbnail: { title: longGerman, description: longParagraph },
186
+ textBrightness: "light",
187
+ href: longUrl,
188
+ },
189
+ {
190
+ src: "https://picsum.photos/seed/stress2/1920/1080",
191
+ alt: "Emoji and HTML-like text",
192
+ stylist: "💇‍♀️✨ SALON 🎉",
193
+ title: "<script>alert('xss')</script>",
194
+ category: "<b>not bold</b> &amp; &lt;tags&gt;",
195
+ description: "🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈",
196
+ thumbnail: { title: "😀 Emoji first", description: "<img src=x onerror=alert(1)>" },
197
+ textBrightness: "dark",
198
+ href: "#emoji",
199
+ },
200
+ {
201
+ src: "https://picsum.photos/seed/stress3/1920/1080",
202
+ alt: "صورة لصالون تصفيف الشعر",
203
+ stylist: "مصففة الشعر",
204
+ title: "تسريحات شعر جديدة لموسم الخريف",
205
+ category: "تلوين الشعر",
206
+ description: "وصف طويل باللغة العربية يمتد عبر عدة أسطر للتحقق من أن النص من اليمين إلى اليسار يعرض بشكل صحيح.",
207
+ thumbnail: { title: "تسريحات", description: "تلوين الشعر" },
208
+ textBrightness: "light",
209
+ },
210
+ {
211
+ src: "https://example.invalid/this-image-does-not-exist.jpg",
212
+ alt: "Broken image URL: the alt text should show instead",
213
+ stylist: "A",
214
+ title: "B",
215
+ category: "C",
216
+ description: "",
217
+ thumbnail: { title: "", description: "" },
218
+ textBrightness: "dark",
219
+ href: "",
220
+ },
221
+ {
222
+ src: "https://picsum.photos/seed/stress5/1920/1080",
223
+ alt: "",
224
+ textBrightness: "light",
225
+ },
226
+ {
227
+ src: "https://picsum.photos/seed/stress6/1920/1080",
228
+ alt: "Unbroken URL as description",
229
+ title: "Link soup",
230
+ description: longUrl,
231
+ thumbnail: { title: longUrl, description: longUrl },
232
+ textBrightness: "light",
233
+ href: "#link-soup",
234
+ },
235
+ ...Array.from({ length: 24 }, (_, index): IGalleryData => ({
236
+ src: `https://picsum.photos/seed/stress-many-${index}/1920/1080`,
237
+ alt: `Filler slide ${index + 7}`,
238
+ title: `Slide ${index + 7}`,
239
+ thumbnail: { title: `Slide ${index + 7}`, description: "Filler" },
240
+ textBrightness: "light",
241
+ })),
242
+ ];
243
+
126
244
  // ─── Stories ──────────────────────────────────────────────────────────────────
127
245
 
246
+ function useStorySetup(args: StoryArgs) {
247
+ const componentArgs = computed(() =>
248
+ Object.fromEntries(Object.entries(args).filter(([arg]) => !(arg in lineClampTokens)))
249
+ );
250
+ const tokenStyles = computed(() =>
251
+ Object.fromEntries(
252
+ Object.entries(lineClampTokens).map(([arg, { token }]) => [token, args[arg as LineClampArg]])
253
+ )
254
+ );
255
+ return { componentArgs, tokenStyles };
256
+ }
257
+
258
+ const renderWith = (slides: IGalleryData[]) => (args: StoryArgs) => ({
259
+ components: { SliderGallery },
260
+ setup() {
261
+ const galleryData = ref<IGalleryData[]>(slides);
262
+ return { ...useStorySetup(args), galleryData };
263
+ },
264
+ template: `<div :style="tokenStyles"><SliderGallery v-bind="componentArgs" v-model:gallery-data="galleryData" /></div>`,
265
+ });
266
+
128
267
  export const Default: Story = {
129
- render: (args) => ({
130
- components: { SliderGallery },
131
- setup() {
132
- const galleryData = ref<IGalleryData[]>(sampleSlides);
133
- return { args, galleryData };
134
- },
135
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
136
- }),
268
+ render: renderWith(sampleSlides),
137
269
  };
138
270
 
139
271
  export const AutoRunDisabled: Story = {
@@ -141,14 +273,7 @@ export const AutoRunDisabled: Story = {
141
273
  args: {
142
274
  autoRun: false,
143
275
  },
144
- render: (args) => ({
145
- components: { SliderGallery },
146
- setup() {
147
- const galleryData = ref<IGalleryData[]>(sampleSlides);
148
- return { args, galleryData };
149
- },
150
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
151
- }),
276
+ render: renderWith(sampleSlides),
152
277
  parameters: {
153
278
  docs: {
154
279
  description: {
@@ -164,14 +289,7 @@ export const FastTransition: Story = {
164
289
  animationDuration: 500,
165
290
  autoRunInterval: 3000,
166
291
  },
167
- render: (args) => ({
168
- components: { SliderGallery },
169
- setup() {
170
- const galleryData = ref<IGalleryData[]>(sampleSlides);
171
- return { args, galleryData };
172
- },
173
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
174
- }),
292
+ render: renderWith(sampleSlides),
175
293
  };
176
294
 
177
295
  export const SlowTransition: Story = {
@@ -180,14 +298,7 @@ export const SlowTransition: Story = {
180
298
  animationDuration: 5000,
181
299
  autoRunInterval: 10000,
182
300
  },
183
- render: (args) => ({
184
- components: { SliderGallery },
185
- setup() {
186
- const galleryData = ref<IGalleryData[]>(sampleSlides);
187
- return { args, galleryData };
188
- },
189
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
190
- }),
301
+ render: renderWith(sampleSlides),
191
302
  };
192
303
 
193
304
  export const SingleSlide: Story = {
@@ -195,14 +306,7 @@ export const SingleSlide: Story = {
195
306
  args: {
196
307
  autoRun: false,
197
308
  },
198
- render: (args) => ({
199
- components: { SliderGallery },
200
- setup() {
201
- const galleryData = ref<IGalleryData[]>([sampleSlides[0]!]);
202
- return { args, galleryData };
203
- },
204
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
205
- }),
309
+ render: renderWith([sampleSlides[0]!]),
206
310
  parameters: {
207
311
  docs: {
208
312
  description: {
@@ -217,18 +321,11 @@ export const MinimalSlideData: Story = {
217
321
  args: {
218
322
  autoRun: false,
219
323
  },
220
- render: (args) => ({
221
- components: { SliderGallery },
222
- setup() {
223
- const galleryData = ref<IGalleryData[]>([
224
- { src: "https://picsum.photos/seed/min1/1920/1080", alt: "Image one", textBrightness: "light" },
225
- { src: "https://picsum.photos/seed/min2/1920/1080", alt: "Image two", textBrightness: "dark" },
226
- { src: "https://picsum.photos/seed/min3/1920/1080", alt: "Image three", textBrightness: "light" },
227
- ]);
228
- return { args, galleryData };
229
- },
230
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
231
- }),
324
+ render: renderWith([
325
+ { src: "https://picsum.photos/seed/min1/1920/1080", alt: "Image one", textBrightness: "light" },
326
+ { src: "https://picsum.photos/seed/min2/1920/1080", alt: "Image two", textBrightness: "dark" },
327
+ { src: "https://picsum.photos/seed/min3/1920/1080", alt: "Image three", textBrightness: "light" },
328
+ ]),
232
329
  parameters: {
233
330
  docs: {
234
331
  description: {
@@ -240,14 +337,7 @@ export const MinimalSlideData: Story = {
240
337
 
241
338
  export const EmptyGallery: Story = {
242
339
  name: "Empty Gallery",
243
- render: (args) => ({
244
- components: { SliderGallery },
245
- setup() {
246
- const galleryData = ref<IGalleryData[]>([]);
247
- return { args, galleryData };
248
- },
249
- template: `<SliderGallery v-bind="args" v-model:gallery-data="galleryData" />`,
250
- }),
340
+ render: renderWith([]),
251
341
  parameters: {
252
342
  docs: {
253
343
  description: {
@@ -256,3 +346,51 @@ export const EmptyGallery: Story = {
256
346
  },
257
347
  },
258
348
  };
349
+
350
+ export const StressTest: Story = {
351
+ name: "Stress Test (Worst-Case Data)",
352
+ args: {
353
+ autoRunInterval: 0,
354
+ loadingText: "Die Galerie wird geladen, bitte haben Sie einen Augenblick Geduld...",
355
+ seeMoreText: "WEITERE INFORMATIONEN ZU DIESEM BEITRAG ANZEIGEN",
356
+ ariaLabel: "",
357
+ },
358
+ render: renderWith(stressSlides),
359
+ decorators: [
360
+ (story, context) => ({
361
+ components: { story, CanvasSwitcher },
362
+ setup() {
363
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
364
+ return { canvasName };
365
+ },
366
+ template: `
367
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
368
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
369
+ </div>
370
+ <div
371
+ :class="canvasName"
372
+ style="position: relative; block-size: 80svh; margin-inline: auto; outline: 1px dashed currentColor; --slider-gallery-width: 100%; --slider-gallery-height: 100%;"
373
+ >
374
+ <story />
375
+ </div>
376
+ `,
377
+ }),
378
+ ],
379
+ parameters: {
380
+ initialCanvas: "mobileCanvas",
381
+ docs: {
382
+ description: {
383
+ story:
384
+ "Deliberately hostile data to find breakage, sized to the canvas with `--slider-gallery-width: 100%` and " +
385
+ "`--slider-gallery-height: 100%`: long German copy and unbroken strings (a 100-character compound word, a long URL) " +
386
+ "in every field including the see-more and loading copy, emoji (including first), HTML-like text (must render as text), " +
387
+ "right-to-left Arabic, a broken image URL (its alt text shows), single characters, empty strings (no empty elements or " +
388
+ "empty link are rendered), a slide with an empty alt and no text, an empty carousel label, and 30 slides. " +
389
+ "`autoRunInterval` is 0: auto-advance is floored to the transition length (minimum 1s), so it keeps cycling rather than " +
390
+ "stalling; try a negative `animationDuration` too. Hover the gallery to pause it while you look. " +
391
+ "Check at every canvas width: no text runs off the slide, thumbnail text stays inside its card (2 lines each by default), " +
392
+ "and the arrows stay usable. Try the line clamp controls too: they set CSS tokens, they are not props.",
393
+ },
394
+ },
395
+ },
396
+ };
@@ -43,9 +43,13 @@ type MockImageInstance = {
43
43
  // ─── Helpers ──────────────────────────────────────────────────────────────────
44
44
 
45
45
  /** Mount the gallery and simulate the first image loading successfully. */
46
- async function mountAndLoad(galleryData: IGalleryData[], mockImage: MockImageInstance) {
46
+ async function mountAndLoad(
47
+ galleryData: IGalleryData[],
48
+ mockImage: MockImageInstance,
49
+ props: Record<string, unknown> = {}
50
+ ) {
47
51
  const wrapper = await mountSuspended(SliderGallery, {
48
- props: { galleryData },
52
+ props: { galleryData, ...props },
49
53
  });
50
54
  mockImage.onload?.();
51
55
  await nextTick(); // let onMounted resume after Promise.race resolves
@@ -310,4 +314,39 @@ describe("SliderGallery", () => {
310
314
  await nextTick();
311
315
  expect(wrapper.classes()).not.toContain("is-next");
312
316
  });
317
+
318
+ // ─── Worst-case numbers ─────────────────────────────────────────────────
319
+
320
+ const activeAlt = (wrapper: Awaited<ReturnType<typeof mountAndLoad>>) =>
321
+ wrapper.find(".slider-gallery-list .slider-gallery-item img").attributes("alt");
322
+
323
+ // Mount with motion allowed, then let the load chain settle so the first auto-run timer is queued.
324
+ async function mountForAutoRun(props: Record<string, unknown>) {
325
+ vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: false })));
326
+ const wrapper = await mountAndLoad(mockGalleryData, mockImage, props);
327
+ vi.advanceTimersByTime(1000);
328
+ await nextTick();
329
+ return wrapper;
330
+ }
331
+
332
+ it("keeps auto-advancing when autoRunInterval is shorter than the transition", async () => {
333
+ const wrapper = await mountForAutoRun({ autoRunInterval: 0, animationDuration: 3000 });
334
+ expect(activeAlt(wrapper)).toBe("Slide 1");
335
+ vi.advanceTimersByTime(3000);
336
+ await nextTick();
337
+ expect(activeAlt(wrapper)).toBe("Slide 2");
338
+ vi.advanceTimersByTime(3000);
339
+ await nextTick();
340
+ expect(activeAlt(wrapper)).toBe("Slide 3");
341
+ });
342
+
343
+ it("floors auto-advance at 1s when both timings are zero or negative", async () => {
344
+ const wrapper = await mountForAutoRun({ autoRunInterval: 0, animationDuration: -500 });
345
+ vi.advanceTimersByTime(999);
346
+ await nextTick();
347
+ expect(activeAlt(wrapper)).toBe("Slide 1");
348
+ vi.advanceTimersByTime(1);
349
+ await nextTick();
350
+ expect(activeAlt(wrapper)).toBe("Slide 2");
351
+ });
313
352
  });
@@ -8,7 +8,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
8
8
  </div>
9
9
  <div class="slider-gallery-content">
10
10
  <div class="slider-gallery-list">
11
- <div class="slider-gallery-item" data-text-brightness="light" data-has-text=""><img data-nuxt-img="" srcset="/_ipx/_/images/slide-1.jpg 1x, /_ipx/_/images/slide-1.jpg 2x" alt="Slide 1" src="/_ipx/_/images/slide-1.jpg">
11
+ <div class="slider-gallery-item" data-text-brightness="light" data-has-text=""><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/slide-1.jpg 1x, /_ipx/s_3840x2160/images/slide-1.jpg 2x" alt="Slide 1" src="/_ipx/s_1920x1080/images/slide-1.jpg">
12
12
  <div class="slider-gallery-item-content light">
13
13
  <div class="slider-gallery-author">Stylist One</div>
14
14
  <div class="slider-gallery-title">First Slide</div>
@@ -17,7 +17,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
17
17
  <!--v-if-->
18
18
  </div>
19
19
  </div>
20
- <div class="slider-gallery-item" data-text-brightness="dark" data-has-text=""><img data-nuxt-img="" srcset="/_ipx/_/images/slide-2.jpg 1x, /_ipx/_/images/slide-2.jpg 2x" alt="Slide 2" src="/_ipx/_/images/slide-2.jpg">
20
+ <div class="slider-gallery-item" data-text-brightness="dark" data-has-text=""><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/slide-2.jpg 1x, /_ipx/s_3840x2160/images/slide-2.jpg 2x" alt="Slide 2" src="/_ipx/s_1920x1080/images/slide-2.jpg">
21
21
  <div class="slider-gallery-item-content dark">
22
22
  <!--v-if-->
23
23
  <div class="slider-gallery-title">Second Slide</div>
@@ -26,7 +26,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
26
26
  <!--v-if-->
27
27
  </div>
28
28
  </div>
29
- <div class="slider-gallery-item" data-text-brightness="light"><img data-nuxt-img="" srcset="/_ipx/_/images/slide-3.jpg 1x, /_ipx/_/images/slide-3.jpg 2x" alt="Slide 3" src="/_ipx/_/images/slide-3.jpg">
29
+ <div class="slider-gallery-item" data-text-brightness="light"><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/slide-3.jpg 1x, /_ipx/s_3840x2160/images/slide-3.jpg 2x" alt="Slide 3" src="/_ipx/s_1920x1080/images/slide-3.jpg">
30
30
  <div class="slider-gallery-item-content light">
31
31
  <!--v-if-->
32
32
  <!--v-if-->
@@ -38,7 +38,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
38
38
  </div>
39
39
  <div class="slider-gallery-thumbnails" aria-hidden="true">
40
40
  <div class="slider-gallery-item">
41
- <div class="slider-gallery-thumbnail-overlay"><img data-nuxt-img="" srcset="/_ipx/_/images/slide-1.jpg 1x, /_ipx/_/images/slide-1.jpg 2x" alt="" loading="lazy" src="/_ipx/_/images/slide-1.jpg">
41
+ <div class="slider-gallery-thumbnail-overlay"><img width="640" height="940" data-nuxt-img="" srcset="/_ipx/s_640x940/images/slide-1.jpg 1x, /_ipx/s_1280x1880/images/slide-1.jpg 2x" alt="" loading="lazy" src="/_ipx/s_640x940/images/slide-1.jpg">
42
42
  <div class="slider-gallery-item-content light">
43
43
  <div class="slider-gallery-title">Thumb 1</div>
44
44
  <div class="slider-gallery-description">Thumb desc 1</div>
@@ -46,7 +46,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
46
46
  </div>
47
47
  </div>
48
48
  <div class="slider-gallery-item">
49
- <div class="slider-gallery-thumbnail-overlay"><img data-nuxt-img="" srcset="/_ipx/_/images/slide-2.jpg 1x, /_ipx/_/images/slide-2.jpg 2x" alt="" loading="lazy" src="/_ipx/_/images/slide-2.jpg">
49
+ <div class="slider-gallery-thumbnail-overlay"><img width="640" height="940" data-nuxt-img="" srcset="/_ipx/s_640x940/images/slide-2.jpg 1x, /_ipx/s_1280x1880/images/slide-2.jpg 2x" alt="" loading="lazy" src="/_ipx/s_640x940/images/slide-2.jpg">
50
50
  <div class="slider-gallery-item-content dark">
51
51
  <div class="slider-gallery-title">Thumb 2</div>
52
52
  <div class="slider-gallery-description">Thumb desc 2</div>
@@ -54,7 +54,7 @@ exports[`SliderGallery > renders correct HTML structure after load 1`] = `
54
54
  </div>
55
55
  </div>
56
56
  <div class="slider-gallery-item">
57
- <div class="slider-gallery-thumbnail-overlay"><img data-nuxt-img="" srcset="/_ipx/_/images/slide-3.jpg 1x, /_ipx/_/images/slide-3.jpg 2x" alt="" loading="lazy" src="/_ipx/_/images/slide-3.jpg">
57
+ <div class="slider-gallery-thumbnail-overlay"><img width="640" height="940" data-nuxt-img="" srcset="/_ipx/s_640x940/images/slide-3.jpg 1x, /_ipx/s_1280x1880/images/slide-3.jpg 2x" alt="" loading="lazy" src="/_ipx/s_640x940/images/slide-3.jpg">
58
58
  <div class="slider-gallery-item-content light">
59
59
  <!--v-if-->
60
60
  <!--v-if-->
@@ -11,6 +11,7 @@ are documented alongside it since that's where they're consumed.
11
11
  |---|---|---|
12
12
  | `--responsive-header-link-font-size` | `inherit` | Nav-link font-size. **Always set this to a fixed value.** Leaving it `inherit` means a fluid ancestor font-size (`clamp()`/`vw`) can drift nav-item text width without ever re-triggering the overflow-collapse measurement — see the component skill doc's "measurement pipeline" section for the full mechanism. |
13
13
  | `--responsive-header-link-color` | `inherit` | Link/summary text colour. |
14
+ | `--responsive-header-main-nav-justify-content` | `space-between` | How the main nav's groups are spread along the bar. Use `flex-start`, `safe center` or `safe end`; plain `center`/`end` let items that don't fit spill off the start edge, where they're clipped. (Safari 16 ignores `safe` values and falls back to start alignment.) |
14
15
  | `--responsive-header-color` | `inherit` | Root element text colour. |
15
16
  | `--responsive-header-margin` | `0` | Root element margin. |
16
17
  | `--responsive-header-bg` | `transparent` | Root element background. |
@@ -20,8 +21,21 @@ are documented alongside it since that's where they're consumed.
20
21
  | `--responsive-header-max-height` | `none` | Root element max-height. |
21
22
  | `--responsive-header-inline-size` | `100%` | Root element inline-size. |
22
23
  | `--responsive-header-sub-nav-bg` / `-border` / `-border-radius` / `-padding` | `Canvas` / `1px solid #efefef75` / `8px` / `12px` | Top-bar dropdown panel (`.main-navigation-sub-nav`). |
24
+ | `--responsive-header-sub-nav-max-inline-size` | `min(48rem, calc(100vw - 3.2rem))` | Widest the top-bar dropdown panel gets; long child names wrap inside it. |
25
+ | `--responsive-header-sub-nav-max-block-size` | `70vh` | Tallest the top-bar dropdown panel gets before it scrolls, so a long child list stays reachable under a sticky header. |
23
26
  | `--responsive-header-overflow-btn-bg` / `-size` / `-border` / `-outline` / `-icon-color` / `-hover-outline` | `Canvas` / `20px` / `1px solid #ffffff90` / `1px solid #ffffff10` / `inherit` / `1px solid #ffffff` | Overflow burger button. |
24
27
  | `--responsive-header-overflow-nav-bg` / `-border` / `-border-radius` / `-padding-block` | `Canvas` / `1px solid #ffffff90` / `8px` / `12px` | Overflow panel container. |
28
+ | `--responsive-header-overflow-nav-max-block-size` | `70vh` | Tallest the overflow panel gets before it scrolls. |
29
+ | `--overflow-nav-max-inline-size` | `calc(100vw - 3.2rem)` | Widest the overflow panel's content gets (set on `NavigationItems`); long names wrap inside it. |
30
+
31
+ Link and dropdown text is never line-clamped: it names where the link goes. Long unbroken names
32
+ wrap inside the dropdown and overflow panels; in the top bar, an item too wide to fit moves to the
33
+ overflow menu.
34
+
35
+ > **Changed 2026-10-05**: the dropdown and overflow panels had no maximum size, so a long child
36
+ > name or a long child list ran off-screen. They are now capped by the four tokens above.
37
+ > `--_overflow-drop-down-width`, a private token nothing ever set, is gone (`min-width: fit-content`
38
+ > directly).
25
39
  | `--responsive-nav-decorator-indicator-color` | `currentColor` | Active-item indicator bar/underline. |
26
40
  | `--responsive-nav-decorator-hovered-indicator-color` | inherits the above | Indicator colour while hovering. |
27
41
  | `--responsive-nav-decorator-hovered-bg` | `oklch(100% 0 0 / 8%)` | Hover highlight background behind the hovered item. |
@@ -66,6 +80,10 @@ Set the tokens above on an element you own (a page or section class, or a class
66
80
 
67
81
  ## Notes
68
82
 
83
+ - Same-page `#anchor` items (since 2026-10-06) render as plain `<a>` with the same classes as route
84
+ links (`.main-navigation-link`, `.overflow-navigation-link`, the sub-nav link classes), so every token
85
+ and selector here applies to them. Their `li.is-active` follows the current hash rather than the route.
86
+
69
87
  - Anything with async-loaded content (an `iconName` icon on a nav item, a new decorator inside
70
88
  a nav link) must reserve its own `width`/`height` in CSS — an unsized element measures at
71
89
  `0` during the initial geometry pass and then pops in afterward with nothing to detect it.