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
@@ -1,24 +1,37 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import AutoGrid from "../AutoGrid.vue";
4
4
 
5
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
6
+
7
+ const mountComponent = async (options: Parameters<typeof mountSuspended<typeof AutoGrid>>[1] = {}) => {
8
+ const wrapper = await mountSuspended(AutoGrid, options);
9
+ mounted.push(wrapper);
10
+ return wrapper;
11
+ };
12
+
13
+ afterEach(() => {
14
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
15
+ vi.restoreAllMocks();
16
+ });
17
+
5
18
  describe("AutoGrid", () => {
6
19
  // ─── Mount ───────────────────────────────────────────────────────────────
7
20
 
8
21
  it("mounts without error", async () => {
9
- const wrapper = await mountSuspended(AutoGrid);
22
+ const wrapper = await mountComponent();
10
23
  expect(wrapper.vm).toBeTruthy();
11
24
  });
12
25
 
13
26
  // ─── Snapshots ───────────────────────────────────────────────────────────
14
27
 
15
28
  it("renders correct HTML structure (default props)", async () => {
16
- const wrapper = await mountSuspended(AutoGrid);
29
+ const wrapper = await mountComponent();
17
30
  expect(wrapper.html()).toMatchSnapshot();
18
31
  });
19
32
 
20
33
  it("renders correct HTML structure (all props and slots set)", async () => {
21
- const wrapper = await mountSuspended(AutoGrid, {
34
+ const wrapper = await mountComponent({
22
35
  props: {
23
36
  tag: "section",
24
37
  isResponsive: true,
@@ -36,29 +49,29 @@ describe("AutoGrid", () => {
36
49
  // ─── Root element ────────────────────────────────────────────────────────
37
50
 
38
51
  it("renders a <div> as the root element by default", async () => {
39
- const wrapper = await mountSuspended(AutoGrid);
52
+ const wrapper = await mountComponent();
40
53
  expect(wrapper.element.tagName).toBe("DIV");
41
54
  });
42
55
 
43
56
  it("always has the auto-grid class", async () => {
44
- const wrapper = await mountSuspended(AutoGrid);
57
+ const wrapper = await mountComponent();
45
58
  expect(wrapper.classes()).toContain("auto-grid");
46
59
  });
47
60
 
48
61
  // ─── Tag prop ────────────────────────────────────────────────────────────
49
62
 
50
63
  it("renders a <section> when tag is section", async () => {
51
- const wrapper = await mountSuspended(AutoGrid, { props: { tag: "section" } });
64
+ const wrapper = await mountComponent({ props: { tag: "section" } });
52
65
  expect(wrapper.element.tagName).toBe("SECTION");
53
66
  });
54
67
 
55
68
  it("renders an <article> when tag is article", async () => {
56
- const wrapper = await mountSuspended(AutoGrid, { props: { tag: "article" } });
69
+ const wrapper = await mountComponent({ props: { tag: "article" } });
57
70
  expect(wrapper.element.tagName).toBe("ARTICLE");
58
71
  });
59
72
 
60
73
  it("renders a <main> when tag is main", async () => {
61
- const wrapper = await mountSuspended(AutoGrid, { props: { tag: "main" } });
74
+ const wrapper = await mountComponent({ props: { tag: "main" } });
62
75
  expect(wrapper.element.tagName).toBe("MAIN");
63
76
  });
64
77
 
@@ -71,13 +84,13 @@ describe("AutoGrid", () => {
71
84
  it.each(["div", "section", "article", "main"] as const)(
72
85
  "does not set aria-labelledby when tag is %s",
73
86
  async (tag) => {
74
- const wrapper = await mountSuspended(AutoGrid, { props: { tag } });
87
+ const wrapper = await mountComponent({ props: { tag } });
75
88
  expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
76
89
  }
77
90
  );
78
91
 
79
92
  it("passes a consumer aria-label through to the root", async () => {
80
- const wrapper = await mountSuspended(AutoGrid, {
93
+ const wrapper = await mountComponent({
81
94
  props: { tag: "section" },
82
95
  attrs: { "aria-label": "Practice stats" },
83
96
  });
@@ -86,27 +99,27 @@ describe("AutoGrid", () => {
86
99
 
87
100
  // ─── isResponsive ────────────────────────────────────────────────────────
88
101
 
89
- it("does not have is-responsive class by default", async () => {
90
- const wrapper = await mountSuspended(AutoGrid);
91
- expect(wrapper.classes()).not.toContain("is-responsive");
102
+ it("does not have data-responsive by default", async () => {
103
+ const wrapper = await mountComponent();
104
+ expect(wrapper.attributes("data-responsive")).toBeUndefined();
92
105
  });
93
106
 
94
- it("adds is-responsive class when isResponsive is true", async () => {
95
- const wrapper = await mountSuspended(AutoGrid, { props: { isResponsive: true } });
96
- expect(wrapper.classes()).toContain("is-responsive");
107
+ it("adds data-responsive when isResponsive is true", async () => {
108
+ const wrapper = await mountComponent({ props: { isResponsive: true } });
109
+ expect(wrapper.attributes("data-responsive")).toBe("");
97
110
  });
98
111
 
99
- it("removes is-responsive class when isResponsive changes to false", async () => {
100
- const wrapper = await mountSuspended(AutoGrid, { props: { isResponsive: true } });
101
- expect(wrapper.classes()).toContain("is-responsive");
112
+ it("removes data-responsive when isResponsive changes to false", async () => {
113
+ const wrapper = await mountComponent({ props: { isResponsive: true } });
114
+ expect(wrapper.attributes("data-responsive")).toBe("");
102
115
  await wrapper.setProps({ isResponsive: false });
103
- expect(wrapper.classes()).not.toContain("is-responsive");
116
+ expect(wrapper.attributes("data-responsive")).toBeUndefined();
104
117
  });
105
118
 
106
119
  // ─── Slots ───────────────────────────────────────────────────────────────
107
120
 
108
121
  it("renders a named slot", async () => {
109
- const wrapper = await mountSuspended(AutoGrid, {
122
+ const wrapper = await mountComponent({
110
123
  slots: { "item-1": "<div class='card'>Card 1</div>" },
111
124
  });
112
125
  expect(wrapper.find(".card").exists()).toBe(true);
@@ -114,7 +127,7 @@ describe("AutoGrid", () => {
114
127
  });
115
128
 
116
129
  it("renders multiple named slots", async () => {
117
- const wrapper = await mountSuspended(AutoGrid, {
130
+ const wrapper = await mountComponent({
118
131
  slots: {
119
132
  "item-1": "<div class='card-1'>Card 1</div>",
120
133
  "item-2": "<div class='card-2'>Card 2</div>",
@@ -127,21 +140,21 @@ describe("AutoGrid", () => {
127
140
  });
128
141
 
129
142
  it("renders no slot content when no slots are provided", async () => {
130
- const wrapper = await mountSuspended(AutoGrid);
143
+ const wrapper = await mountComponent();
131
144
  expect(wrapper.text()).toBe("");
132
145
  });
133
146
 
134
147
  // ─── styleClassPassthrough ───────────────────────────────────────────────
135
148
 
136
149
  it("applies a single styleClassPassthrough string to the root", async () => {
137
- const wrapper = await mountSuspended(AutoGrid, {
150
+ const wrapper = await mountComponent({
138
151
  props: { styleClassPassthrough: "my-grid" },
139
152
  });
140
153
  expect(wrapper.classes()).toContain("my-grid");
141
154
  });
142
155
 
143
156
  it("applies multiple styleClassPassthrough classes from an array", async () => {
144
- const wrapper = await mountSuspended(AutoGrid, {
157
+ const wrapper = await mountComponent({
145
158
  props: { styleClassPassthrough: ["my-grid", "mbe-32"] },
146
159
  });
147
160
  expect(wrapper.classes()).toContain("my-grid");
@@ -149,7 +162,7 @@ describe("AutoGrid", () => {
149
162
  });
150
163
 
151
164
  it("updates classes when styleClassPassthrough prop changes", async () => {
152
- const wrapper = await mountSuspended(AutoGrid, {
165
+ const wrapper = await mountComponent({
153
166
  props: { styleClassPassthrough: ["original"] },
154
167
  });
155
168
  expect(wrapper.classes()).toContain("original");
@@ -1,7 +1,7 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`AutoGrid > renders correct HTML structure (all props and slots set) 1`] = `
4
- "<section class="auto-grid custom-class is-responsive">
4
+ "<section class="auto-grid custom-class" data-responsive="">
5
5
  <div>Item 1</div>
6
6
  <div>Item 2</div>
7
7
  <div>Item 3</div>
@@ -24,6 +24,15 @@ part of the public token API. See `.claude/skills/components/column-flow-grid.md
24
24
 
25
25
  ---
26
26
 
27
+ ## State hooks
28
+
29
+ No state attributes. Element classes: `.column-flow-grid-wrapper` (root, the multi-column box) and
30
+ `.column-flow-grid-item` (each slot, `break-inside: avoid`, wraps long words since 2026-10-21).
31
+
32
+ Private (not public API): `--_column-flow-grid-gap` and `--_column-flow-grid-item-min-width`, set
33
+ inline from the `gap`/`unit` and `itemMinWidth` props. The second was `--_item-min-width` until
34
+ 2026-10-21, a generic name that leaked into slotted components.
35
+
27
36
  ## Local overrides
28
37
 
29
38
  Set the tokens above on an element you own (a page or section class, or a class added with
@@ -3,7 +3,7 @@
3
3
  :is="tag"
4
4
  class="column-flow-grid-wrapper"
5
5
  :class="[elementClasses]"
6
- :style="`--_column-flow-grid-gap: ${gap}${unit}; --_item-min-width: ${itemMinWidth}px`"
6
+ :style="`--_column-flow-grid-gap: ${gap}${unit}; --_column-flow-grid-item-min-width: ${itemMinWidth}px`"
7
7
  >
8
8
  <div v-for="(_, name) in $slots" :key="name" class="column-flow-grid-item">
9
9
  <slot :name="name"></slot>
@@ -41,11 +41,12 @@ watch(
41
41
  <style lang="css">
42
42
  @layer components {
43
43
  .column-flow-grid-wrapper {
44
- columns: auto var(--_item-min-width);
44
+ columns: auto var(--_column-flow-grid-item-min-width);
45
45
  column-gap: var(--_column-flow-grid-gap);
46
46
 
47
47
  .column-flow-grid-item {
48
48
  break-inside: avoid;
49
+ overflow-wrap: anywhere;
49
50
  outline: 0.1rem solid var(--column-flow-grid-item-border-colour, var(--theme-border));
50
51
  padding: var(--column-flow-grid-item-padding, 1.2rem);
51
52
  margin-block-end: var(--_column-flow-grid-gap);
@@ -1,4 +1,7 @@
1
+ import { ref } from "vue";
1
2
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
3
+ import type { MediaCanvas } from "~/types/components";
4
+ import CanvasSwitcher from "../../../canvas-switcher/CanvasSwitcher.vue";
2
5
  import ColumnFlowGrid from "../ColumnFlowGrid.vue";
3
6
 
4
7
  interface ColumnFlowGridArgs {
@@ -176,3 +179,46 @@ export const TwoItems: StoryFn<ColumnFlowGridArgs> = (args) => ({
176
179
  `,
177
180
  });
178
181
  TwoItems.storyName = "Two Items (no count prop needed)";
182
+
183
+ export const StressTest: StoryFn = () => ({
184
+ components: { ColumnFlowGrid },
185
+ template: `
186
+ <ColumnFlowGrid :item-min-width="240">
187
+ <template #item-1><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><h3 style="margin: 0;">Supercalifragilisticexpialidociousandthensomemoretextwithoutanyspaces</h3><p style="margin: 0;">https://example.com/a/very/long/unbroken/path/that/never/wraps/on/its/own</p></div></template>
188
+ <template #item-2><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;">Ein sehr langer Beschreibungstext aus Northtown, der auf schmalen Bildschirmen mehrfach umbrechen muss, ohne das Raster zu sprengen oder die Nachbarspalten zusammenzudrücken.</p></div></template>
189
+ <template #item-3><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><img src="/images/banners/banner-1.webp" width="1600" height="400" alt="" style="display: block; max-inline-size: 100%; block-size: auto;"></div></template>
190
+ <template #item-4><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;">🙂 👍🏽 👨‍👩‍👧 &lt;b&gt;Not bold&lt;/b&gt;</p></div></template>
191
+ <template #item-5><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;" dir="rtl">مرحبا بالعالم، نص طويل من اليمين إلى اليسار</p></div></template>
192
+ <template #item-6><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"></div></template>
193
+ <template #item-7><div style="padding: 1.2rem; background: var(--theme-surface-subtle); outline: 1px dashed currentColor;"><p style="margin: 0;">x</p></div></template>
194
+ </ColumnFlowGrid>
195
+ `,
196
+ });
197
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
198
+ StressTest.decorators = [
199
+ (story, context) => ({
200
+ components: { story, CanvasSwitcher },
201
+ setup() {
202
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
203
+ return { canvasName };
204
+ },
205
+ template: `
206
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
207
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
208
+ </div>
209
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
210
+ <story />
211
+ </div>
212
+ `,
213
+ }),
214
+ ];
215
+ StressTest.parameters = {
216
+ layout: "fullscreen",
217
+ docs: {
218
+ story: { inline: false, height: "720px" },
219
+ description: {
220
+ story:
221
+ "Oversized and hostile slot content at every `CanvasSwitcher` width. Check that: the unbroken heading and URL wrap inside their cell instead of widening it; the German paragraph wraps; the 1600px image scales down to its cell; emoji, `<b>` and right-to-left text render as text; the empty and one-character cells keep their place; no neighbouring cell is squeezed to nothing and nothing scrolls sideways. Items flow down each column, so reading order runs top to bottom, then across.",
222
+ },
223
+ },
224
+ };
@@ -1,27 +1,40 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import ColumnFlowGrid from "../ColumnFlowGrid.vue";
4
4
 
5
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
6
+
7
+ const mountComponent = async (options: Parameters<typeof mountSuspended<typeof ColumnFlowGrid>>[1] = {}) => {
8
+ const wrapper = await mountSuspended(ColumnFlowGrid, options);
9
+ mounted.push(wrapper);
10
+ return wrapper;
11
+ };
12
+
13
+ afterEach(() => {
14
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
15
+ vi.restoreAllMocks();
16
+ });
17
+
5
18
  describe("ColumnFlowGrid", () => {
6
19
  it("mounts without error", async () => {
7
- const wrapper = await mountSuspended(ColumnFlowGrid);
20
+ const wrapper = await mountComponent();
8
21
  expect(wrapper.vm).toBeTruthy();
9
22
  });
10
23
 
11
24
  it("renders as div by default", async () => {
12
- const wrapper = await mountSuspended(ColumnFlowGrid);
25
+ const wrapper = await mountComponent();
13
26
  expect(wrapper.element.tagName).toBe("DIV");
14
27
  });
15
28
 
16
29
  it("renders a different tag when provided", async () => {
17
- const wrapper = await mountSuspended(ColumnFlowGrid, {
30
+ const wrapper = await mountComponent({
18
31
  props: { tag: "section" },
19
32
  });
20
33
  expect(wrapper.element.tagName).toBe("SECTION");
21
34
  });
22
35
 
23
36
  it("renders slot content", async () => {
24
- const wrapper = await mountSuspended(ColumnFlowGrid, {
37
+ const wrapper = await mountComponent({
25
38
  slots: {
26
39
  "item-1": "<div>Item 1</div>",
27
40
  },
@@ -30,7 +43,7 @@ describe("ColumnFlowGrid", () => {
30
43
  });
31
44
 
32
45
  it("renders each provided slot as its own column-flow-grid-item", async () => {
33
- const wrapper = await mountSuspended(ColumnFlowGrid, {
46
+ const wrapper = await mountComponent({
34
47
  slots: {
35
48
  "item-1": "<div>Item 1</div>",
36
49
  "item-2": "<div>Item 2</div>",
@@ -45,28 +58,28 @@ describe("ColumnFlowGrid", () => {
45
58
  });
46
59
 
47
60
  it("renders no items when no slots are provided", async () => {
48
- const wrapper = await mountSuspended(ColumnFlowGrid);
61
+ const wrapper = await mountComponent();
49
62
  expect(wrapper.findAll(".column-flow-grid-item")).toHaveLength(0);
50
63
  });
51
64
 
52
65
  it("applies itemMinWidth and gap as inline CSS custom properties", async () => {
53
- const wrapper = await mountSuspended(ColumnFlowGrid, {
66
+ const wrapper = await mountComponent({
54
67
  props: { itemMinWidth: 250, gap: 2, unit: "em" },
55
68
  });
56
69
  const style = (wrapper.element as HTMLElement).style;
57
- expect(style.getPropertyValue("--_item-min-width")).toBe("250px");
70
+ expect(style.getPropertyValue("--_column-flow-grid-item-min-width")).toBe("250px");
58
71
  expect(style.getPropertyValue("--_column-flow-grid-gap")).toBe("2em");
59
72
  });
60
73
 
61
74
  it("applies styleClassPassthrough", async () => {
62
- const wrapper = await mountSuspended(ColumnFlowGrid, {
75
+ const wrapper = await mountComponent({
63
76
  props: { styleClassPassthrough: "custom-class" },
64
77
  });
65
78
  expect(wrapper.classes()).toContain("custom-class");
66
79
  });
67
80
 
68
81
  it("applies multiple styleClassPassthrough classes", async () => {
69
- const wrapper = await mountSuspended(ColumnFlowGrid, {
82
+ const wrapper = await mountComponent({
70
83
  props: { styleClassPassthrough: ["class-a", "class-b"] },
71
84
  });
72
85
  expect(wrapper.classes()).toContain("class-a");
@@ -31,6 +31,9 @@ There are no `data-*` attributes or state classes.
31
31
 
32
32
  ---
33
33
 
34
+ The stack's single column is `minmax(0, 1fr)` and each layer has `min-inline-size: 0` (since
35
+ 2026-10-21), so a layer with an unbroken string can't widen the stack past its container.
36
+
34
37
  ## Local overrides
35
38
 
36
39
  Set the tokens above on an element you own (a page or section class, or a class added with
@@ -31,9 +31,11 @@ watch(
31
31
  display: grid;
32
32
  grid-template-areas: "stack";
33
33
  grid-template-rows: 100%;
34
+ grid-template-columns: minmax(0, 1fr);
34
35
 
35
36
  .grid-stack__layer {
36
37
  grid-area: stack;
38
+ min-inline-size: 0;
37
39
  }
38
40
  }
39
41
  }
@@ -1,4 +1,7 @@
1
+ import { ref } from "vue";
1
2
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
3
+ import type { MediaCanvas } from "~/types/components";
4
+ import CanvasSwitcher from "../../../canvas-switcher/CanvasSwitcher.vue";
2
5
  import GridStackComponent from "../GridStack.vue";
3
6
  import BannerVideoComponent from "../../../banner-video/BannerVideo.vue";
4
7
 
@@ -160,3 +163,41 @@ WithVideoOverlay.parameters = {
160
163
  },
161
164
  },
162
165
  };
166
+
167
+ export const StressTest: StoryFn = () => ({
168
+ components: { GridStackComponent },
169
+ template: `
170
+ <GridStackComponent>
171
+ <template #background><img src="/images/banners/banner-1.webp" width="1600" height="400" alt="" style="display: block; max-inline-size: 100%; block-size: auto;"></template>
172
+ <template #overlay><div style="align-self: end; padding: 1.2rem; background: color-mix(in oklab, var(--theme-surface-subtle) 85%, transparent);"><h3 style="margin: 0;">Supercalifragilisticexpialidociousandthensomemoretextwithoutanyspaces</h3><p style="margin: 0;">https://example.com/a/very/long/unbroken/path/that/never/wraps/on/its/own</p><p style="margin: 0;">Ein sehr langer Beschreibungstext aus Northtown, der auf schmalen Bildschirmen mehrfach umbrechen muss, ohne das Raster zu sprengen oder die Nachbarspalten zusammenzudrücken.</p><p style="margin: 0;" dir="rtl">مرحبا بالعالم، نص طويل من اليمين إلى اليسار</p></div></template>
173
+ </GridStackComponent>
174
+ `,
175
+ });
176
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
177
+ StressTest.decorators = [
178
+ (story, context) => ({
179
+ components: { story, CanvasSwitcher },
180
+ setup() {
181
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
182
+ return { canvasName };
183
+ },
184
+ template: `
185
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
186
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
187
+ </div>
188
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
189
+ <story />
190
+ </div>
191
+ `,
192
+ }),
193
+ ];
194
+ StressTest.parameters = {
195
+ layout: "fullscreen",
196
+ docs: {
197
+ story: { inline: false, height: "720px" },
198
+ description: {
199
+ story:
200
+ "Oversized and hostile slot content at every `CanvasSwitcher` width. Check that: the unbroken heading and URL wrap inside their cell instead of widening it; the German paragraph wraps; the 1600px image scales down to its cell; emoji, `<b>` and right-to-left text render as text; the empty and one-character cells keep their place; no neighbouring cell is squeezed to nothing and nothing scrolls sideways. Here the stress is one layer whose text is far wider than the image layer beneath it: both stay inside the stack.",
201
+ },
202
+ },
203
+ };
@@ -1,27 +1,40 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import GridStack from "../GridStack.vue";
4
4
 
5
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
6
+
7
+ const mountComponent = async (options: Parameters<typeof mountSuspended<typeof GridStack>>[1] = {}) => {
8
+ const wrapper = await mountSuspended(GridStack, options);
9
+ mounted.push(wrapper);
10
+ return wrapper;
11
+ };
12
+
13
+ afterEach(() => {
14
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
15
+ vi.restoreAllMocks();
16
+ });
17
+
5
18
  describe("GridStack", () => {
6
19
  it("mounts without error", async () => {
7
- const wrapper = await mountSuspended(GridStack);
20
+ const wrapper = await mountComponent();
8
21
  expect(wrapper.vm).toBeTruthy();
9
22
  });
10
23
 
11
24
  it("renders as div by default", async () => {
12
- const wrapper = await mountSuspended(GridStack);
25
+ const wrapper = await mountComponent();
13
26
  expect(wrapper.element.tagName).toBe("DIV");
14
27
  });
15
28
 
16
29
  it("renders a different tag when provided", async () => {
17
- const wrapper = await mountSuspended(GridStack, {
30
+ const wrapper = await mountComponent({
18
31
  props: { tag: "section" },
19
32
  });
20
33
  expect(wrapper.element.tagName).toBe("SECTION");
21
34
  });
22
35
 
23
36
  it("renders slot content", async () => {
24
- const wrapper = await mountSuspended(GridStack, {
37
+ const wrapper = await mountComponent({
25
38
  slots: {
26
39
  "layer-1": "<div>Layer 1</div>",
27
40
  },
@@ -30,7 +43,7 @@ describe("GridStack", () => {
30
43
  });
31
44
 
32
45
  it("renders multiple slots as stacked layers", async () => {
33
- const wrapper = await mountSuspended(GridStack, {
46
+ const wrapper = await mountComponent({
34
47
  slots: {
35
48
  "layer-1": "<div>Layer 1</div>",
36
49
  "layer-2": "<div>Layer 2</div>",
@@ -45,19 +58,19 @@ describe("GridStack", () => {
45
58
  });
46
59
 
47
60
  it("renders no layers when no slots are provided", async () => {
48
- const wrapper = await mountSuspended(GridStack);
61
+ const wrapper = await mountComponent();
49
62
  expect(wrapper.findAll(".grid-stack__layer")).toHaveLength(0);
50
63
  });
51
64
 
52
65
  it("applies styleClassPassthrough", async () => {
53
- const wrapper = await mountSuspended(GridStack, {
66
+ const wrapper = await mountComponent({
54
67
  props: { styleClassPassthrough: "custom-class" },
55
68
  });
56
69
  expect(wrapper.classes()).toContain("custom-class");
57
70
  });
58
71
 
59
72
  it("applies multiple styleClassPassthrough classes", async () => {
60
- const wrapper = await mountSuspended(GridStack, {
73
+ const wrapper = await mountComponent({
61
74
  props: { styleClassPassthrough: ["class-a", "class-b"] },
62
75
  });
63
76
  expect(wrapper.classes()).toContain("class-a");
@@ -65,7 +78,7 @@ describe("GridStack", () => {
65
78
  });
66
79
 
67
80
  it("updates classes when styleClassPassthrough prop changes", async () => {
68
- const wrapper = await mountSuspended(GridStack, {
81
+ const wrapper = await mountComponent({
69
82
  props: { styleClassPassthrough: ["original"] },
70
83
  });
71
84
  await wrapper.setProps({ styleClassPassthrough: ["updated"] });
@@ -74,12 +87,12 @@ describe("GridStack", () => {
74
87
  });
75
88
 
76
89
  it("always has the grid-stack class", async () => {
77
- const wrapper = await mountSuspended(GridStack);
90
+ const wrapper = await mountComponent();
78
91
  expect(wrapper.classes()).toContain("grid-stack");
79
92
  });
80
93
 
81
94
  it("passes a consumer aria-label through to the root", async () => {
82
- const wrapper = await mountSuspended(GridStack, {
95
+ const wrapper = await mountComponent({
83
96
  props: { tag: "section" },
84
97
  attrs: { "aria-label": "Hero" },
85
98
  });
@@ -88,7 +101,7 @@ describe("GridStack", () => {
88
101
  });
89
102
 
90
103
  it("renders the base layer first so later slots paint on top", async () => {
91
- const wrapper = await mountSuspended(GridStack, {
104
+ const wrapper = await mountComponent({
92
105
  slots: {
93
106
  "layer-1": "<div class='base'>Base</div>",
94
107
  "layer-2": "<div class='overlay'>Overlay</div>",
@@ -26,6 +26,21 @@ computed in JS from measured pixel values. See `.claude/skills/components/masonr
26
26
 
27
27
  ---
28
28
 
29
+ ## State hooks
30
+
31
+ | Hook | Meaning |
32
+ |---|---|
33
+ | `.masonry-grid-wrapper[data-multiple-cols]` | More than one column fits, so tiles are absolutely positioned by script |
34
+
35
+ Element classes: `.masonry-grid` (root), `.masonry-grid-wrapper`, `.masonry-grid-item`.
36
+
37
+ Private (not public API), set inline by script: `--_masonry-grid-item-position`,
38
+ `--_masonry-grid-item-top`, `--_masonry-grid-item-left` and `--_masonry-grid-item-width` on each tile and `--_masonry-grid-height` on the wrapper.
39
+
40
+ > Changed 2026-10-21: the state was the class `.multiple-cols`, and the private tokens were
41
+ > `--_position`, `--_position-top`, `--_position-left`, `--_element-width` and `--_wrapper-height`:
42
+ > generic names that inherited into components placed inside a tile.
43
+
29
44
  ## Local overrides
30
45
 
31
46
  Set the tokens above on an element you own (a page or section class, or a class added with