srcdev-nuxt-components 9.6.19 → 9.6.20

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 (135) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +3 -2
  2. package/.claude/skills/component-compliance-checklist.md +5 -0
  3. package/.claude/skills/components/auto-grid.md +3 -1
  4. package/.claude/skills/components/capture-qr-code.md +9 -5
  5. package/.claude/skills/components/carousel-flip.md +41 -30
  6. package/.claude/skills/components/column-flow-grid.md +2 -0
  7. package/.claude/skills/components/dashboard-quad-grid.md +7 -2
  8. package/.claude/skills/components/dashboard-stats-grid.md +4 -0
  9. package/.claude/skills/components/decode-qr-code.md +7 -2
  10. package/.claude/skills/components/deep-expanding-menu-classic.md +31 -30
  11. package/.claude/skills/components/deep-expanding-menu.md +22 -21
  12. package/.claude/skills/components/grid-stack.md +2 -0
  13. package/.claude/skills/components/layout-grid-by-cols.md +2 -0
  14. package/.claude/skills/components/masonry-grid.md +9 -1
  15. package/.claude/skills/components/page-row.md +3 -0
  16. package/.claude/skills/components/samaritan-prompt.md +24 -8
  17. package/.claude/skills/components/site-header.md +16 -2
  18. package/.claude/skills/index.md +12 -12
  19. package/.claude/skills/storybook-add-story.md +25 -0
  20. package/.claude/skills/testing-add-unit-test.md +26 -0
  21. package/.vscode/srcdev-component-capture-decode-qr-code.code-snippets +11 -2
  22. package/.vscode/srcdev-component-carousel-flip.code-snippets +22 -4
  23. package/.vscode/srcdev-component-deep-expanding-menu-classic.code-snippets +32 -2
  24. package/.vscode/srcdev-component-deep-expanding-menu.code-snippets +31 -1
  25. package/.vscode/srcdev-component-samaritan-prompt.code-snippets +5 -4
  26. package/.vscode/srcdev-component-site-header.code-snippets +13 -1
  27. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +12 -2
  28. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +19 -17
  29. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +42 -0
  30. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/tests/DashboardQuadGrid.spec.ts +31 -44
  31. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +13 -1
  32. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +40 -31
  33. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/stories/DashboardStatsGrid.stories.ts +47 -0
  34. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/tests/DashboardStatsGrid.spec.ts +51 -53
  35. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/CONSUMER-STYLING.md +3 -0
  36. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/LayoutGridByCols.vue +4 -0
  37. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/stories/LayoutGridByCols.stories.ts +47 -2
  38. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/LayoutGridByCols.spec.ts +36 -23
  39. package/app/components/01.atoms/grids/auto-grid/AutoGrid.vue +7 -3
  40. package/app/components/01.atoms/grids/auto-grid/CONSUMER-STYLING.md +1 -1
  41. package/app/components/01.atoms/grids/auto-grid/stories/AutoGrid.stories.ts +46 -1
  42. package/app/components/01.atoms/grids/auto-grid/tests/AutoGrid.spec.ts +40 -27
  43. package/app/components/01.atoms/grids/auto-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
  44. package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +9 -0
  45. package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +3 -2
  46. package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +46 -0
  47. package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +24 -11
  48. package/app/components/01.atoms/grids/grid-stack/CONSUMER-STYLING.md +3 -0
  49. package/app/components/01.atoms/grids/grid-stack/GridStack.vue +2 -0
  50. package/app/components/01.atoms/grids/grid-stack/stories/GridStack.stories.ts +41 -0
  51. package/app/components/01.atoms/grids/grid-stack/tests/GridStack.spec.ts +26 -13
  52. package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +15 -0
  53. package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +51 -31
  54. package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +48 -0
  55. package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +84 -29
  56. package/app/components/01.atoms/page-row/CONSUMER-STYLING.md +6 -1
  57. package/app/components/01.atoms/page-row/PageRow.vue +16 -15
  58. package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +23 -0
  59. package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +6 -5
  60. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +63 -44
  61. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +74 -37
  62. package/app/components/02.molecules/navigation/deep-expanding-menu/stories/DeepExpandingMenu.stories.ts +139 -19
  63. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +90 -27
  64. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +8 -8
  65. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +58 -26
  66. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +113 -57
  67. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/stories/DeepExpandingMenuClassic.stories.ts +139 -19
  68. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/DeepExpandingMenuClassic.spec.ts +140 -22
  69. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/__snapshots__/DeepExpandingMenuClassic.spec.ts.snap +9 -9
  70. package/app/components/02.molecules/qr-code/CONSUMER-STYLING.md +22 -7
  71. package/app/components/02.molecules/qr-code/CaptureQrCode.vue +63 -71
  72. package/app/components/02.molecules/qr-code/DecodeQrCode.vue +47 -10
  73. package/app/components/02.molecules/qr-code/stories/CaptureQrCode.stories.ts +114 -26
  74. package/app/components/02.molecules/qr-code/stories/DecodeQrCode.stories.ts +115 -24
  75. package/app/components/02.molecules/qr-code/tests/CaptureQrCode.spec.ts +124 -51
  76. package/app/components/02.molecules/qr-code/tests/DecodeQrCode.spec.ts +74 -25
  77. package/app/components/02.molecules/samaritan-prompt/CONSUMER-STYLING.md +22 -10
  78. package/app/components/02.molecules/samaritan-prompt/SamaritanPrompt.vue +30 -12
  79. package/app/components/02.molecules/samaritan-prompt/SamaritanPromptMixed.vue +34 -10
  80. package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPrompt.stories.ts +55 -4
  81. package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPromptMixed.stories.ts +67 -7
  82. package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPrompt.spec.ts +113 -29
  83. package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed.spec.ts +92 -45
  84. package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +82 -29
  85. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +176 -113
  86. package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +153 -23
  87. package/app/components/03.organisms/image-galleries/carousel-flip/tests/CarouselFlip.spec.ts +119 -63
  88. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +8 -8
  89. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -4
  90. package/app/components/03.organisms/site-header/CONSUMER-STYLING.md +41 -23
  91. package/app/components/03.organisms/site-header/SiteHeader.vue +16 -21
  92. package/app/components/03.organisms/site-header/stories/SiteHeader.stories.ts +138 -0
  93. package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +53 -26
  94. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +2 -2
  95. package/app/types/components/qr-code.d.ts +12 -0
  96. package/app/utils/graphemes.ts +8 -0
  97. package/package.json +1 -1
  98. package/app/components/01.atoms/animations/entry/.nuxt/app.config.mjs +0 -21
  99. package/app/components/01.atoms/animations/entry/.nuxt/components.d.ts +0 -66
  100. package/app/components/01.atoms/animations/entry/.nuxt/imports.d.ts +0 -30
  101. package/app/components/01.atoms/animations/entry/.nuxt/manifest/meta/test.json +0 -1
  102. package/app/components/01.atoms/animations/entry/.nuxt/types/app.config.d.ts +0 -35
  103. package/app/components/01.atoms/animations/entry/.nuxt/types/build.d.ts +0 -23
  104. package/app/components/01.atoms/animations/entry/.nuxt/types/builder-env.d.ts +0 -1
  105. package/app/components/01.atoms/animations/entry/.nuxt/types/components.d.ts +0 -71
  106. package/app/components/01.atoms/animations/entry/.nuxt/types/imports.d.ts +0 -386
  107. package/app/components/01.atoms/animations/entry/.nuxt/types/middleware.d.ts +0 -11
  108. package/app/components/01.atoms/animations/entry/.nuxt/types/modules.d.ts +0 -79
  109. package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-layouts.d.ts +0 -17
  110. package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-nuxt.d.ts +0 -64
  111. package/app/components/01.atoms/animations/entry/.nuxt/types/plugins.d.ts +0 -29
  112. package/app/components/01.atoms/animations/entry/.nuxt/types/runtime-config.d.ts +0 -32
  113. package/app/components/01.atoms/animations/entry/.nuxt/types/shared-imports.d.ts +0 -10
  114. package/app/components/01.atoms/animations/entry/.nuxt/types/vue-shim.d.ts +0 -0
  115. package/app/components/01.atoms/animations/entry/.nuxtrc +0 -1
  116. package/app/components/01.atoms/animations/entry/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
  117. package/app/components/01.atoms/animations/entry/node_modules/.cache/vite/client/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  118. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +0 -21
  119. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +0 -66
  120. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +0 -30
  121. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +0 -1
  122. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +0 -35
  123. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +0 -23
  124. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +0 -1
  125. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +0 -71
  126. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +0 -386
  127. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +0 -11
  128. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +0 -79
  129. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +0 -17
  130. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +0 -64
  131. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +0 -29
  132. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +0 -32
  133. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +0 -10
  134. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  135. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
@@ -77,13 +77,6 @@ vi.mock("@vueuse/core", () => ({
77
77
  }),
78
78
  }));
79
79
 
80
- // Mock Icon component
81
- vi.mock("#imports", () => ({
82
- useStyleClassPassthrough: vi.fn((classes) => ({
83
- elementClasses: Array.isArray(classes) ? classes.join(" ") : classes || "",
84
- })),
85
- }));
86
-
87
80
  describe("CarouselFlip", () => {
88
81
  let wrapper: ReturnType<typeof mountSuspended>;
89
82
  let component: CarouselFlipInstance;
@@ -137,7 +130,7 @@ describe("CarouselFlip", () => {
137
130
  it("renders with required accessibility attributes", async () => {
138
131
  await createWrapper();
139
132
 
140
- const carousel = wrapper.find('[role="region"]');
133
+ const carousel = wrapper.find('section.carousel-flip');
141
134
  expect(carousel.exists()).toBe(true);
142
135
  expect(carousel.attributes("aria-label")).toBe("Image carousel");
143
136
 
@@ -157,7 +150,7 @@ describe("CarouselFlip", () => {
157
150
  it("renders all carousel items with correct slots", async () => {
158
151
  await createWrapper();
159
152
 
160
- const items = wrapper.findAll(".item");
153
+ const items = wrapper.findAll(".carousel-flip-item");
161
154
  expect(items).toHaveLength(6);
162
155
 
163
156
  for (let i = 0; i < 6; i++) {
@@ -170,7 +163,7 @@ describe("CarouselFlip", () => {
170
163
  it("renders marker buttons for each item", async () => {
171
164
  await createWrapper();
172
165
 
173
- const markers = wrapper.findAll(".btn-marker");
166
+ const markers = wrapper.findAll(".carousel-flip-marker");
174
167
  expect(markers).toHaveLength(6);
175
168
 
176
169
  markers.forEach((marker: ReturnType<typeof wrapper.find>, index: number) => {
@@ -182,8 +175,8 @@ describe("CarouselFlip", () => {
182
175
  it("renders navigation buttons with correct icons and labels", async () => {
183
176
  await createWrapper();
184
177
 
185
- const prevButton = wrapper.find('[aria-label="Go to previous item"]');
186
- const nextButton = wrapper.find('[aria-label="Go to next item"]');
178
+ const prevButton = wrapper.find('.carousel-flip-button-prev');
179
+ const nextButton = wrapper.find('.carousel-flip-button-next');
187
180
 
188
181
  expect(prevButton.exists()).toBe(true);
189
182
  expect(nextButton.exists()).toBe(true);
@@ -201,7 +194,7 @@ describe("CarouselFlip", () => {
201
194
  carouselDataIds: customData,
202
195
  });
203
196
 
204
- const items = wrapper.findAll(".item");
197
+ const items = wrapper.findAll(".carousel-flip-item");
205
198
  expect(items).toHaveLength(3);
206
199
  expect(component.itemCount).toBe(3);
207
200
  });
@@ -223,11 +216,11 @@ describe("CarouselFlip", () => {
223
216
  // The transition speed is used in computed property transitionSpeedStr
224
217
  });
225
218
 
226
- it("applies allowCarouselOverflow class conditionally", async () => {
219
+ it("sets data-allow-overflow on the track when allowCarouselOverflow is on", async () => {
227
220
  await createWrapper({ allowCarouselOverflow: true });
228
221
 
229
- const container = wrapper.find(".item-container");
230
- expect(container.classes()).toContain("allow-overflow");
222
+ const container = wrapper.find(".carousel-flip-track");
223
+ expect(container.attributes("data-allow-overflow")).toBe("");
231
224
  });
232
225
 
233
226
  it("handles useFlipAnimation prop in calculations", async () => {
@@ -248,29 +241,29 @@ describe("CarouselFlip", () => {
248
241
  it("shows controls container by default", async () => {
249
242
  await createWrapper();
250
243
 
251
- expect(wrapper.find(".controls-container").exists()).toBe(true);
252
- expect(wrapper.findAll(".btn-marker").length).toBe(6);
244
+ expect(wrapper.find(".carousel-flip-controls").exists()).toBe(true);
245
+ expect(wrapper.findAll(".carousel-flip-marker").length).toBe(6);
253
246
  });
254
247
 
255
248
  it("hides controls container when showControls is false", async () => {
256
249
  await createWrapper({ showControls: false });
257
250
 
258
- expect(wrapper.find(".controls-container").exists()).toBe(false);
259
- expect(wrapper.findAll(".btn-marker").length).toBe(0);
251
+ expect(wrapper.find(".carousel-flip-controls").exists()).toBe(false);
252
+ expect(wrapper.findAll(".carousel-flip-marker").length).toBe(0);
260
253
  });
261
254
 
262
255
  it("still renders navigation buttons when showControls is false", async () => {
263
256
  await createWrapper({ showControls: false });
264
257
 
265
- expect(wrapper.find('[aria-label="Go to previous item"]').exists()).toBe(true);
266
- expect(wrapper.find('[aria-label="Go to next item"]').exists()).toBe(true);
258
+ expect(wrapper.find('.carousel-flip-button-prev').exists()).toBe(true);
259
+ expect(wrapper.find('.carousel-flip-button-next').exists()).toBe(true);
267
260
  });
268
261
 
269
262
  it("navigation still works when showControls is false", async () => {
270
263
  await createWrapper({ showControls: false });
271
264
  await nextTick();
272
265
 
273
- const nextButton = wrapper.find('[aria-label="Go to next item"]');
266
+ const nextButton = wrapper.find('.carousel-flip-button-next');
274
267
  await nextButton.trigger("click");
275
268
  await nextTick();
276
269
 
@@ -292,10 +285,10 @@ describe("CarouselFlip", () => {
292
285
  jumpToItemLabel: "View look",
293
286
  });
294
287
 
295
- expect(wrapper.find('[role="region"]').attributes("aria-label")).toBe("Featured looks");
288
+ expect(wrapper.find('section.carousel-flip').attributes("aria-label")).toBe("Featured looks");
296
289
  expect(wrapper.find('[role="group"]').attributes("aria-label")).toBe("Look slides");
297
- expect(wrapper.find('[aria-label="Previous look"]').exists()).toBe(true);
298
- expect(wrapper.find('[aria-label="Next look"]').exists()).toBe(true);
290
+ expect(wrapper.find('.carousel-flip-button-prev').exists()).toBe(true);
291
+ expect(wrapper.find('.carousel-flip-button-next').exists()).toBe(true);
299
292
  expect(wrapper.find('[aria-label="View look 1"]').exists()).toBe(true);
300
293
  });
301
294
 
@@ -310,8 +303,8 @@ describe("CarouselFlip", () => {
310
303
  it("overrides prev/next icons via props", async () => {
311
304
  await createWrapper({ prevIcon: "mdi:chevron-left", nextIcon: "mdi:chevron-right" });
312
305
 
313
- const prevButton = wrapper.find('[aria-label="Go to previous item"]');
314
- const nextButton = wrapper.find('[aria-label="Go to next item"]');
306
+ const prevButton = wrapper.find('.carousel-flip-button-prev');
307
+ const nextButton = wrapper.find('.carousel-flip-button-next');
315
308
 
316
309
  expect(prevButton.html()).toContain("i-mdi:chevron-left");
317
310
  expect(nextButton.html()).toContain("i-mdi:chevron-right");
@@ -345,7 +338,7 @@ describe("CarouselFlip", () => {
345
338
  await createWrapper();
346
339
  await nextTick();
347
340
 
348
- const nextButton = wrapper.find('[aria-label="Go to next item"]');
341
+ const nextButton = wrapper.find('.carousel-flip-button-next');
349
342
  await nextButton.trigger("click");
350
343
  await nextTick();
351
344
 
@@ -361,15 +354,16 @@ describe("CarouselFlip", () => {
361
354
  expect(component.currentActiveIndex).toBe(0);
362
355
  expect(component.displayActiveIndex).toBe(0);
363
356
 
364
- const firstMarker = wrapper.find(".btn-marker");
365
- expect(firstMarker.classes()).toContain("active");
357
+ const firstMarker = wrapper.find(".carousel-flip-marker");
358
+ expect(firstMarker.attributes("data-active")).toBe("");
359
+ expect(firstMarker.attributes("aria-current")).toBe("true");
366
360
  });
367
361
 
368
362
  it("sets up item order and z-index correctly", async () => {
369
363
  await createWrapper();
370
364
  await nextTick();
371
365
 
372
- const items = wrapper.findAll(".item");
366
+ const items = wrapper.findAll(".carousel-flip-item");
373
367
  // After initialization, items are reordered by the component
374
368
  // Just verify that all items have order and z-index set
375
369
  items.forEach((item: ReturnType<typeof wrapper.find>) => {
@@ -378,12 +372,12 @@ describe("CarouselFlip", () => {
378
372
  });
379
373
  });
380
374
 
381
- it("adds mounted class after initialization", async () => {
375
+ it("sets data-mounted after initialization", async () => {
382
376
  await createWrapper();
383
377
  await nextTick();
384
378
  await nextTick(); // Wait for nextTick in component
385
379
 
386
- expect(wrapper.classes()).toContain("mounted");
380
+ expect(wrapper.attributes("data-mounted")).toBe("");
387
381
  });
388
382
 
389
383
  it("marks carousel as initialized", async () => {
@@ -401,7 +395,7 @@ describe("CarouselFlip", () => {
401
395
  });
402
396
 
403
397
  it("navigates to next item when next button is clicked", async () => {
404
- const nextButton = wrapper.find('[aria-label="Go to next item"]');
398
+ const nextButton = wrapper.find('.carousel-flip-button-next');
405
399
  await nextButton.trigger("click");
406
400
  await nextTick();
407
401
 
@@ -410,7 +404,7 @@ describe("CarouselFlip", () => {
410
404
  });
411
405
 
412
406
  it("navigates to previous item when previous button is clicked", async () => {
413
- const prevButton = wrapper.find('[aria-label="Go to previous item"]');
407
+ const prevButton = wrapper.find('.carousel-flip-button-prev');
414
408
  await prevButton.trigger("click");
415
409
  await nextTick();
416
410
 
@@ -423,7 +417,7 @@ describe("CarouselFlip", () => {
423
417
  component.currentActiveIndex = 5;
424
418
  await nextTick();
425
419
 
426
- const nextButton = wrapper.find('[aria-label="Go to next item"]');
420
+ const nextButton = wrapper.find('.carousel-flip-button-next');
427
421
  await nextButton.trigger("click");
428
422
  await nextTick();
429
423
 
@@ -431,7 +425,7 @@ describe("CarouselFlip", () => {
431
425
  });
432
426
 
433
427
  it("wraps around when navigating before first item", async () => {
434
- const prevButton = wrapper.find('[aria-label="Go to previous item"]');
428
+ const prevButton = wrapper.find('.carousel-flip-button-prev');
435
429
  await prevButton.trigger("click");
436
430
  await nextTick();
437
431
 
@@ -439,7 +433,7 @@ describe("CarouselFlip", () => {
439
433
  });
440
434
 
441
435
  it("jumps to specific item when marker is clicked", async () => {
442
- const thirdMarker = wrapper.findAll(".btn-marker")[2];
436
+ const thirdMarker = wrapper.findAll(".carousel-flip-marker")[2];
443
437
  await thirdMarker.trigger("click");
444
438
  await nextTick();
445
439
 
@@ -448,17 +442,18 @@ describe("CarouselFlip", () => {
448
442
  });
449
443
 
450
444
  it("updates active marker visually", async () => {
451
- const thirdMarker = wrapper.findAll(".btn-marker")[2];
445
+ const thirdMarker = wrapper.findAll(".carousel-flip-marker")[2];
452
446
  await thirdMarker.trigger("click");
453
447
  await nextTick();
454
448
 
455
- const markers = wrapper.findAll(".btn-marker");
456
- expect(markers[0].classes()).not.toContain("active");
457
- expect(markers[2].classes()).toContain("active");
449
+ const markers = wrapper.findAll(".carousel-flip-marker");
450
+ expect(markers[0].attributes("data-active")).toBeUndefined();
451
+ expect(markers[2].attributes("data-active")).toBe("");
452
+ expect(markers[2].attributes("aria-current")).toBe("true");
458
453
  });
459
454
 
460
455
  it("updates screen reader announcement when navigating", async () => {
461
- const nextButton = wrapper.find('[aria-label="Go to next item"]');
456
+ const nextButton = wrapper.find('.carousel-flip-button-next');
462
457
  await nextButton.trigger("click");
463
458
  await nextTick();
464
459
 
@@ -467,12 +462,12 @@ describe("CarouselFlip", () => {
467
462
  });
468
463
 
469
464
  it("updates aria-current for active item", async () => {
470
- const nextButton = wrapper.find('[aria-label="Go to next item"]');
465
+ const nextButton = wrapper.find('.carousel-flip-button-next');
471
466
  await nextButton.trigger("click");
472
467
  await nextTick();
473
468
 
474
- const items = wrapper.findAll(".item");
475
- expect(items[0].attributes("aria-current")).toBe("false");
469
+ const items = wrapper.findAll(".carousel-flip-item");
470
+ expect(items[0].attributes("aria-current")).toBeUndefined();
476
471
  expect(items[1].attributes("aria-current")).toBe("true");
477
472
  });
478
473
  });
@@ -506,7 +501,7 @@ describe("CarouselFlip", () => {
506
501
  });
507
502
 
508
503
  it("ignores other keys", async () => {
509
- const container = wrapper.find(".item-container");
504
+ const container = wrapper.find(".carousel-flip-track");
510
505
  const initialIndex = component.currentActiveIndex;
511
506
 
512
507
  await container.trigger("keydown", { key: "Space" });
@@ -557,16 +552,16 @@ describe("CarouselFlip", () => {
557
552
  await nextTick();
558
553
  });
559
554
 
560
- it("applies loaded class after user interaction", async () => {
561
- const items = wrapper.findAll(".item");
562
- expect(items[0].classes()).not.toContain("loaded");
555
+ it("sets data-loaded after user interaction", async () => {
556
+ const items = wrapper.findAll(".carousel-flip-item");
557
+ expect(items[0].attributes("data-loaded")).toBeUndefined();
563
558
 
564
- const nextButton = wrapper.find('[aria-label="Go to next item"]');
559
+ const nextButton = wrapper.find('.carousel-flip-button-next');
565
560
  await nextButton.trigger("click");
566
561
  await nextTick();
567
562
 
568
- const updatedItems = wrapper.findAll(".item");
569
- expect(updatedItems[0].classes()).toContain("loaded");
563
+ const updatedItems = wrapper.findAll(".carousel-flip-item");
564
+ expect(updatedItems[0].attributes("data-loaded")).toBe("");
570
565
  });
571
566
 
572
567
  it("calls reorderItems without animation during initial setup", async () => {
@@ -580,7 +575,7 @@ describe("CarouselFlip", () => {
580
575
  // Test that navigation works which implicitly tests reorderItems is called
581
576
  const initialIndex = component.currentActiveIndex;
582
577
 
583
- const nextButton = wrapper.find('[aria-label="Go to next item"]');
578
+ const nextButton = wrapper.find('.carousel-flip-button-next');
584
579
  await nextButton.trigger("click");
585
580
  await nextTick();
586
581
 
@@ -594,30 +589,31 @@ describe("CarouselFlip", () => {
594
589
  it("handles empty carousel data", async () => {
595
590
  await createWrapper({ carouselDataIds: [] });
596
591
 
597
- const items = wrapper.findAll(".item");
592
+ const items = wrapper.findAll(".carousel-flip-item");
598
593
  expect(items).toHaveLength(0);
599
594
  expect(component.itemCount).toBe(0);
600
595
 
601
- const markers = wrapper.findAll(".btn-marker");
596
+ const markers = wrapper.findAll(".carousel-flip-marker");
602
597
  expect(markers).toHaveLength(0);
603
598
  });
604
599
 
605
600
  it("handles single item carousel", async () => {
606
601
  await createWrapper({ carouselDataIds: ["single-item"] });
607
602
 
608
- const items = wrapper.findAll(".item");
603
+ const items = wrapper.findAll(".carousel-flip-item");
609
604
  expect(items).toHaveLength(1);
610
605
 
611
- const markers = wrapper.findAll(".btn-marker");
612
- expect(markers).toHaveLength(1);
613
- expect(markers[0].classes()).toContain("active");
606
+ expect(wrapper.findAll(".carousel-flip-marker")).toHaveLength(0);
607
+ expect(wrapper.find(".carousel-flip-button-prev").exists()).toBe(false);
608
+ expect(wrapper.find(".carousel-flip-button-next").exists()).toBe(false);
609
+ expect(items[0]!.attributes("aria-current")).toBe("true");
614
610
  });
615
611
 
616
612
  it("prevents navigation when carousel is not initialized", async () => {
617
613
  await createWrapper();
618
614
  component.carouselInitComplete = false;
619
615
 
620
- const nextButton = wrapper.find('[aria-label="Go to next item"]');
616
+ const nextButton = wrapper.find('.carousel-flip-button-next');
621
617
  await nextButton.trigger("click");
622
618
  await nextTick();
623
619
 
@@ -655,7 +651,7 @@ describe("CarouselFlip", () => {
655
651
  it("applies transition speed as CSS custom property", async () => {
656
652
  await createWrapper({ transitionSpeed: 300 });
657
653
 
658
- const items = wrapper.findAll(".item");
654
+ const items = wrapper.findAll(".carousel-flip-item");
659
655
  if (items.length > 0) {
660
656
  // CSS custom properties are handled by Vue's reactivity system
661
657
  expect(component).toBeDefined();
@@ -672,4 +668,64 @@ describe("CarouselFlip", () => {
672
668
  expect(component).toBeDefined();
673
669
  });
674
670
  });
671
+
672
+ describe("Worst-case data", () => {
673
+ it("mounts with no items without hanging, and renders no buttons or markers", async () => {
674
+ await createWrapper({ carouselDataIds: [] });
675
+ await nextTick();
676
+ expect(wrapper.findAll(".carousel-flip-item")).toHaveLength(0);
677
+ expect(wrapper.find(".carousel-flip-buttons").exists()).toBe(false);
678
+ expect(wrapper.find(".carousel-flip-controls").exists()).toBe(false);
679
+ expect(wrapper.find("[aria-live]").text()).toBe("");
680
+ });
681
+
682
+ it("keeps the item count in step when carouselDataIds changes after mount", async () => {
683
+ await createWrapper({ carouselDataIds: ["item-1", "item-2"] });
684
+ expect(wrapper.findAll(".carousel-flip-marker")).toHaveLength(2);
685
+
686
+ await wrapper.setProps({ carouselDataIds: ["item-1", "item-2", "item-3", "item-4"] });
687
+ expect(wrapper.findAll(".carousel-flip-marker")).toHaveLength(4);
688
+ expect(wrapper.find("[aria-live]").text()).toContain("of 4");
689
+ });
690
+
691
+ it("resets to the first item when the active one is removed", async () => {
692
+ await createWrapper();
693
+ component.jumpToFrame(5);
694
+ await nextTick();
695
+ await wrapper.setProps({ carouselDataIds: ["item-1", "item-2"] });
696
+ await nextTick();
697
+ expect(component.currentActiveIndex).toBe(0);
698
+ });
699
+
700
+ it("renders items whose ids repeat without dropping one", async () => {
701
+ await createWrapper({ carouselDataIds: ["item-1", "item-1", "item-2"] });
702
+ expect(wrapper.findAll(".carousel-flip-item")).toHaveLength(3);
703
+ });
704
+
705
+ it("gives the controls bar no tab stop of its own", async () => {
706
+ await createWrapper();
707
+ expect(wrapper.find(".carousel-flip-controls").attributes("tabindex")).toBeUndefined();
708
+ });
709
+
710
+ it("replaces styleClassPassthrough classes when the prop changes", async () => {
711
+ await createWrapper({ styleClassPassthrough: ["first"] });
712
+ await wrapper.setProps({ styleClassPassthrough: ["second"] });
713
+ expect(wrapper.classes()).not.toContain("first");
714
+ expect(wrapper.classes()).toContain("second");
715
+ });
716
+
717
+ it("renders both buttons inside the buttons group in the track-edges layout", async () => {
718
+ await createWrapper({ buttonLayout: "track-edges" });
719
+ expect(wrapper.attributes("data-button-layout")).toBe("track-edges");
720
+ const group = wrapper.find(".carousel-flip-buttons");
721
+ expect(group.find(".carousel-flip-button-prev").exists()).toBe(true);
722
+ expect(group.find(".carousel-flip-button-next").exists()).toBe(true);
723
+ });
724
+
725
+ it("names the prev and next buttons with their visually hidden text", async () => {
726
+ await createWrapper({ previousLabel: "Previous look", nextLabel: "Next look" });
727
+ expect(wrapper.find(".carousel-flip-button-prev").text()).toContain("Previous look");
728
+ expect(wrapper.find(".carousel-flip-button-next").text()).toContain("Next look");
729
+ });
730
+ });
675
731
  });
@@ -129,38 +129,38 @@ describe("ServiceDetail", () => {
129
129
 
130
130
  it("defaults heroImageVariant to full, heroContentVariant and bodyVariant to content", async () => {
131
131
  const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
132
- expect(wrapper.find(".service-detail__hero-image-row").classes()).toContain("full");
133
- expect(wrapper.find(".service-detail__hero-overlay").classes()).toContain("content");
134
- expect(wrapper.find(".service-detail__body-row").classes()).toContain("content");
132
+ expect(wrapper.find(".service-detail__hero-image-row").attributes("data-variant")).toBe("full");
133
+ expect(wrapper.find(".service-detail__hero-overlay").attributes("data-variant")).toBe("content");
134
+ expect(wrapper.find(".service-detail__body-row").attributes("data-variant")).toBe("content");
135
135
  });
136
136
 
137
137
  it("applies the heroImageVariant prop to the hero image PageRow", async () => {
138
138
  const wrapper = await mountSuspended(ServiceDetail, {
139
139
  props: { serviceData: mockService, heroImageVariant: "popout" },
140
140
  });
141
- expect(wrapper.find(".service-detail__hero-image-row").classes()).toContain("popout");
141
+ expect(wrapper.find(".service-detail__hero-image-row").attributes("data-variant")).toBe("popout");
142
142
  });
143
143
 
144
144
  it("applies the heroContentVariant prop to the hero content PageRow", async () => {
145
145
  const wrapper = await mountSuspended(ServiceDetail, {
146
146
  props: { serviceData: mockService, heroContentVariant: "inset-content" },
147
147
  });
148
- expect(wrapper.find(".service-detail__hero-overlay").classes()).toContain("inset-content");
148
+ expect(wrapper.find(".service-detail__hero-overlay").attributes("data-variant")).toBe("inset-content");
149
149
  });
150
150
 
151
151
  it("applies the bodyVariant prop to the body PageRow", async () => {
152
152
  const wrapper = await mountSuspended(ServiceDetail, {
153
153
  props: { serviceData: mockService, bodyVariant: "popout" },
154
154
  });
155
- expect(wrapper.find(".service-detail__body-row").classes()).toContain("popout");
155
+ expect(wrapper.find(".service-detail__body-row").attributes("data-variant")).toBe("popout");
156
156
  });
157
157
 
158
158
  it("defaults finalCtaVariant to content and applies the prop to the final CTA PageRow", async () => {
159
159
  const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
160
- expect(wrapper.find(".service-detail__final-cta-row").classes()).toContain("content");
160
+ expect(wrapper.find(".service-detail__final-cta-row").attributes("data-variant")).toBe("content");
161
161
 
162
162
  await wrapper.setProps({ finalCtaVariant: "full" });
163
- expect(wrapper.find(".service-detail__final-cta-row").classes()).toContain("full");
163
+ expect(wrapper.find(".service-detail__final-cta-row").attributes("data-variant")).toBe("full");
164
164
  });
165
165
 
166
166
  // ─── Service data ───────────────────────────────────────────────────────
@@ -4,10 +4,10 @@ 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 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>
7
+ <div class="page-row service-detail__hero-image-row" data-variant="full"><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
- <div class="page-row content service-detail__hero-overlay">
10
+ <div class="page-row service-detail__hero-overlay" data-variant="content">
11
11
  <div class="service-detail__hero-content">
12
12
  <nav class="breadcrumb service-detail__breadcrumb" aria-label="Breadcrumb">
13
13
  <ol class="breadcrumb__list">
@@ -26,7 +26,7 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
26
26
  </div>
27
27
  </div>
28
28
  </div>
29
- <div class="page-row content service-detail__body-row">
29
+ <div class="page-row service-detail__body-row" data-variant="content">
30
30
  <div class="service-detail__body">
31
31
  <div class="service-detail__main">
32
32
  <p class="page-body-normal">Balayage is a freehand hair colouring technique that creates a soft gradient.</p>
@@ -95,7 +95,7 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
95
95
  </aside>
96
96
  </div>
97
97
  </div>
98
- <div class="page-row content service-detail__final-cta-row">
98
+ <div class="page-row service-detail__final-cta-row" data-variant="content">
99
99
  <div class="service-detail__final-cta">
100
100
  <div class="service-detail__final-cta-copy">
101
101
  <h2 class="hero-text" data-font-size="subheading" data-axis="horizontal">
@@ -2,43 +2,57 @@
2
2
 
3
3
  ## Public token API
4
4
 
5
- All `--site-header-*` tokens style the outer `.site-header` grid row (branding column + nav
6
- column). They're the stable override surface — set them at any scope without touching the
7
- component itself.
5
+ All `--site-header-*` tokens style the header row. They're the stable override surface: set them
6
+ at any scope without touching the component itself.
8
7
 
9
8
  | Token | Default | Controls |
10
9
  |---|---|---|
11
10
  | `--site-header-gap` | `2.4rem` | Gap between the branding column and the nav column |
11
+ | `--site-header-branding-max-width` | `50%` | Widest the branding column may grow (`fit-content()`); past it, the branding wraps |
12
12
  | `--site-header-padding-inline` | `2.4rem` | Horizontal padding on `.site-header` |
13
13
  | `--site-header-padding-block` | `0px` | Vertical padding on `.site-header` |
14
- | `--site-header-bg` | `transparent` | Background colour of `.site-header` |
14
+ | `--site-header-bg` | `transparent` | Background colour of `.site-header` (the content column, not the full-width row) |
15
15
  | `--site-header-position` | `static` | Set to `sticky` to pin the header, paired with `--site-header-sticky-offset` |
16
16
  | `--site-header-sticky-offset` | `0px` | `top` offset when `--site-header-position: sticky` |
17
- | `--site-header-z-index` | `9` | Stacking order of `.site-header` |
17
+ | `--site-header-z-index` | `9` | Stacking order of the row |
18
18
 
19
- `SiteHeader` composes `PageRow` + `SkipLinks` + `ResponsiveHeader` internally. Two separate
20
- `styleClassPassthrough` props reach the two composed components independently:
19
+ > Changed 2026-10-21: `--site-header-position`, `--site-header-sticky-offset` and
20
+ > `--site-header-z-index` now apply to the outer row (`.site-header-row`, the `PageRow`) instead of
21
+ > the inner `<header>`. On the inner header, `sticky` never stuck: its parent is exactly its own
22
+ > height, so there was nowhere for it to move. The branding column was `auto` and is now capped by
23
+ > `--site-header-branding-max-width`, so a long brand can't squeeze the nav to nothing.
21
24
 
22
- - `styleClassPassthrough` → forwarded to the root `PageRow` (targets `--site-header-*` tokens above)
23
- - `navStyleClassPassthrough` → forwarded to the nested `ResponsiveHeader` (targets its own
24
- `--responsive-header-*` and `--overflow-nav-*` tokens — see
25
- [../../responsive-header/CONSUMER-STYLING.md](../../responsive-header/CONSUMER-STYLING.md) if present,
26
- or the `ResponsiveHeader` skill doc)
25
+ No private `--_` tokens.
27
26
 
28
- ---
27
+ `SiteHeader` composes `PageRow` + `SkipLinks` + `ResponsiveHeader`. Two separate
28
+ `styleClassPassthrough` props reach the composed components independently:
29
+
30
+ - `styleClassPassthrough` → the root `PageRow` (set `--site-header-*` tokens there)
31
+ - `navStyleClassPassthrough` → the nested `ResponsiveHeader` (its own `--responsive-header-*` and
32
+ `--overflow-nav-*` tokens; see `ResponsiveHeader`'s `CONSUMER-STYLING.md`)
33
+
34
+ ## State hooks
35
+
36
+ No state attributes. Element classes:
37
+
38
+ | Class | Element |
39
+ |---|---|
40
+ | `.site-header-row` | Root `PageRow` (with `.page-row` and the variant class); carries the sticky position |
41
+ | `.site-header` | The `<header>` grid: branding column + nav column |
42
+ | `.site-header-home-nav` | The branding `<nav>` wrapping `SkipLinks` and the `#branding` slot |
43
+
44
+ > Changed 2026-10-21: `.site-header-home-nav` was `.home-navigation`, and `.site-header-row` is new.
29
45
 
30
46
  ## Global theming
31
47
 
32
- Create `assets/styles/setup/07.components/site-header.css` in the consuming app and set tokens
33
- on a scope class matching the one passed via `styleClassPassthrough`:
48
+ Set tokens on the scope class passed via `styleClassPassthrough`:
34
49
 
35
50
  ```css
36
- /* assets/styles/setup/07.components/site-header.css */
37
51
  .header {
38
52
  --site-header-gap: 1.6rem;
39
53
  --site-header-padding-inline: 1.2rem;
40
54
  --site-header-position: sticky;
41
- --site-header-bg: #00000095;
55
+ --site-header-bg: var(--theme-surface-subtle);
42
56
  }
43
57
  ```
44
58
 
@@ -50,8 +64,6 @@ on a scope class matching the one passed via `styleClassPassthrough`:
50
64
  </SiteHeader>
51
65
  ```
52
66
 
53
- ---
54
-
55
67
  ## Local overrides
56
68
 
57
69
  Set the tokens above on an element you own (a page or section class, or a class added with
@@ -60,12 +72,18 @@ Set the tokens above on an element you own (a page or section class, or a class
60
72
  `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
61
73
  `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
62
74
 
63
- ---
75
+ **Caveat:** sticky positioning needs the row's own parent to be taller than the row (normally the
76
+ page layout) and no `overflow` other than `visible` on the ancestors between it and the scroller.
77
+
78
+ ## Class passthrough
79
+
80
+ `style-class-passthrough` adds classes to the root `PageRow`; `nav-style-class-passthrough` to
81
+ `ResponsiveHeader`'s root.
64
82
 
65
83
  ## Notes
66
84
 
67
85
  - The branding markup (logo image, wordmark, etc.) is entirely consumer-authored via the
68
- `#branding` slot — `SiteHeader` has no opinion on brand styling.
86
+ `#branding` slot; `SiteHeader` has no opinion on brand styling. Long branding wraps
87
+ (`overflow-wrap: anywhere`) once it reaches `--site-header-branding-max-width`.
69
88
  - `SkipLinks`' hardcoded anchors (`#main-content`, `#footer-content`) expect the consuming
70
- layout's main/footer regions to carry those `id`s — unchanged from using `SkipLinks` directly.
71
-
89
+ layout's main/footer regions to carry those `id`s.