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,8 +1,30 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import { nextTick } from "vue";
4
4
  import CaptureQrCode from "../CaptureQrCode.vue";
5
5
 
6
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
7
+
8
+ type CaptureMountOptions = Parameters<typeof mountSuspended<typeof CaptureQrCode>>[1];
9
+
10
+ // The real QrcodeStream queues camera start/stop on a module-level promise chain shared by every
11
+ // instance, so a start left over from an earlier test can fail mid-test and flip state.error.
12
+ const qrcodeStreamStub = { name: "QrcodeStream", emits: ["detect", "error"], template: `<div class="qrcode-stream-stub"></div>` };
13
+
14
+ const mountCapture = async (options: CaptureMountOptions = {}) => {
15
+ const wrapper = await mountSuspended(CaptureQrCode, {
16
+ ...options,
17
+ global: { ...options?.global, stubs: { QrcodeStream: qrcodeStreamStub, ...options?.global?.stubs } },
18
+ });
19
+ mounted.push(wrapper);
20
+ return wrapper;
21
+ };
22
+
23
+ afterEach(() => {
24
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
25
+ vi.restoreAllMocks();
26
+ });
27
+
6
28
  interface CaptureVM {
7
29
  state: { error: boolean; errorMsg: string; cameraOn: boolean };
8
30
  result: string[] | undefined;
@@ -12,63 +34,52 @@ interface CaptureVM {
12
34
  }
13
35
 
14
36
  describe("CaptureQrCode", () => {
15
- // QrcodeStream (from nuxt-qrcode) runs canvas-based detection internally.
16
- // jsdom defines HTMLCanvasElement but returns null for getContext, which
17
- // causes the library to throw. We provide a minimal non-null mock.
18
- beforeEach(() => {
19
- vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as unknown as RenderingContext);
20
- });
21
-
22
- afterEach(() => {
23
- vi.restoreAllMocks();
24
- });
25
-
26
37
  // ─── Mount ───────────────────────────────────────────────────────────────
27
38
 
28
39
  it("mounts without error", async () => {
29
- const wrapper = await mountSuspended(CaptureQrCode);
40
+ const wrapper = await mountCapture();
30
41
  expect(wrapper.vm).toBeTruthy();
31
42
  });
32
43
 
33
44
  // ─── Root element ─────────────────────────────────────────────────────────
34
45
 
35
46
  it("renders as a div", async () => {
36
- const wrapper = await mountSuspended(CaptureQrCode);
47
+ const wrapper = await mountCapture();
37
48
  expect(wrapper.element.tagName).toBe("DIV");
38
49
  });
39
50
 
40
51
  it("always has the capture-qr-code class", async () => {
41
- const wrapper = await mountSuspended(CaptureQrCode);
52
+ const wrapper = await mountCapture();
42
53
  expect(wrapper.classes()).toContain("capture-qr-code");
43
54
  });
44
55
 
45
56
  // ─── Initial state ────────────────────────────────────────────────────────
46
57
 
47
58
  it("has camera on by default", async () => {
48
- const wrapper = await mountSuspended(CaptureQrCode);
59
+ const wrapper = await mountCapture();
49
60
  const vm = wrapper.vm as unknown as CaptureVM;
50
61
  expect(vm.state.cameraOn).toBe(true);
51
62
  });
52
63
 
53
64
  it("does not show camera-stopped on mount", async () => {
54
- const wrapper = await mountSuspended(CaptureQrCode);
65
+ const wrapper = await mountCapture();
55
66
  expect(wrapper.find(".capture-qr-code-stopped").exists()).toBe(false);
56
67
  });
57
68
 
58
69
  it("does not show scanned results initially", async () => {
59
- const wrapper = await mountSuspended(CaptureQrCode);
70
+ const wrapper = await mountCapture();
60
71
  expect(wrapper.find(".capture-qr-code-results ul").exists()).toBe(false);
61
72
  });
62
73
 
63
74
  it("does not show the error state initially", async () => {
64
- const wrapper = await mountSuspended(CaptureQrCode);
75
+ const wrapper = await mountCapture();
65
76
  expect(wrapper.find(".capture-qr-code-error").exists()).toBe(false);
66
77
  });
67
78
 
68
79
  // ─── Camera off ───────────────────────────────────────────────────────────
69
80
 
70
81
  it("hides the stream and shows camera-stopped when cameraOn is false", async () => {
71
- const wrapper = await mountSuspended(CaptureQrCode);
82
+ const wrapper = await mountCapture();
72
83
  const vm = wrapper.vm as unknown as CaptureVM;
73
84
  vm.state.cameraOn = false;
74
85
  await nextTick();
@@ -79,23 +90,49 @@ describe("CaptureQrCode", () => {
79
90
  // ─── Error state ──────────────────────────────────────────────────────────
80
91
 
81
92
  it("shows camera-error when onError is called", async () => {
82
- const wrapper = await mountSuspended(CaptureQrCode);
93
+ const wrapper = await mountCapture();
83
94
  const vm = wrapper.vm as unknown as CaptureVM;
84
95
  vm.onError(new Error("Permission denied"));
85
96
  await nextTick();
86
97
  expect(wrapper.find(".capture-qr-code-error").exists()).toBe(true);
87
98
  });
88
99
 
89
- it("shows the error message in the error state", async () => {
90
- const wrapper = await mountSuspended(CaptureQrCode);
100
+ it("shows a plain-language message for a known error", async () => {
101
+ const wrapper = await mountCapture();
102
+ const err = new Error("Permission denied");
103
+ err.name = "NotAllowedError";
104
+ (wrapper.vm as unknown as CaptureVM).onError(err);
105
+ await nextTick();
106
+ expect(wrapper.find(".capture-qr-code-error-message").text()).toBe(
107
+ "Camera access was blocked. Allow it in your browser settings, then try again."
108
+ );
109
+ });
110
+
111
+ it("falls back to the default message for an unknown error", async () => {
112
+ const wrapper = await mountCapture();
113
+ (wrapper.vm as unknown as CaptureVM).onError(new Error("Something odd"));
114
+ await nextTick();
115
+ expect(wrapper.find(".capture-qr-code-error-message").text()).toBe("The camera couldn't be started. Try again.");
116
+ });
117
+
118
+ it("uses errorMessages overrides, per error and as the default", async () => {
119
+ const wrapper = await mountCapture({
120
+ props: { errorMessages: { NotFoundError: "Aucune caméra", default: "Erreur" } },
121
+ });
91
122
  const vm = wrapper.vm as unknown as CaptureVM;
92
- vm.onError(new Error("Permission denied"));
123
+ const notFound = new Error("x");
124
+ notFound.name = "NotFoundError";
125
+ vm.onError(notFound);
93
126
  await nextTick();
94
- expect(wrapper.find(".capture-qr-code-error p").text()).toContain("Permission denied");
127
+ expect(wrapper.find(".capture-qr-code-error-message").text()).toBe("Aucune caméra");
128
+
129
+ vm.onError(new Error("unknown"));
130
+ await nextTick();
131
+ expect(wrapper.find(".capture-qr-code-error-message").text()).toBe("Erreur");
95
132
  });
96
133
 
97
134
  it("sets state.error when onError is called, hiding the stream", async () => {
98
- const wrapper = await mountSuspended(CaptureQrCode);
135
+ const wrapper = await mountCapture();
99
136
  const vm = wrapper.vm as unknown as CaptureVM;
100
137
  vm.onError(new Error("test"));
101
138
  await nextTick();
@@ -104,7 +141,7 @@ describe("CaptureQrCode", () => {
104
141
  });
105
142
 
106
143
  it("shows a reset button in the error state", async () => {
107
- const wrapper = await mountSuspended(CaptureQrCode);
144
+ const wrapper = await mountCapture();
108
145
  const vm = wrapper.vm as unknown as CaptureVM;
109
146
  vm.onError(new Error("test"));
110
147
  await nextTick();
@@ -114,7 +151,7 @@ describe("CaptureQrCode", () => {
114
151
  // ─── Reset camera ─────────────────────────────────────────────────────────
115
152
 
116
153
  it("clears the error state when resetCamera is called", async () => {
117
- const wrapper = await mountSuspended(CaptureQrCode);
154
+ const wrapper = await mountCapture();
118
155
  const vm = wrapper.vm as unknown as CaptureVM;
119
156
  vm.onError(new Error("test"));
120
157
  await nextTick();
@@ -125,7 +162,7 @@ describe("CaptureQrCode", () => {
125
162
  });
126
163
 
127
164
  it("clears the error state when the reset button is clicked", async () => {
128
- const wrapper = await mountSuspended(CaptureQrCode);
165
+ const wrapper = await mountCapture();
129
166
  const vm = wrapper.vm as unknown as CaptureVM;
130
167
  vm.onError(new Error("test"));
131
168
  await nextTick();
@@ -136,7 +173,7 @@ describe("CaptureQrCode", () => {
136
173
  // ─── Detect ───────────────────────────────────────────────────────────────
137
174
 
138
175
  it("shows scanned results after onDetect is called", async () => {
139
- const wrapper = await mountSuspended(CaptureQrCode);
176
+ const wrapper = await mountCapture();
140
177
  const vm = wrapper.vm as unknown as CaptureVM;
141
178
  vm.onDetect([{ rawValue: "https://example.com" }]);
142
179
  await nextTick();
@@ -144,7 +181,7 @@ describe("CaptureQrCode", () => {
144
181
  });
145
182
 
146
183
  it("shows the detected value in the results list", async () => {
147
- const wrapper = await mountSuspended(CaptureQrCode);
184
+ const wrapper = await mountCapture();
148
185
  const vm = wrapper.vm as unknown as CaptureVM;
149
186
  vm.onDetect([{ rawValue: "https://example.com" }]);
150
187
  await nextTick();
@@ -152,7 +189,7 @@ describe("CaptureQrCode", () => {
152
189
  });
153
190
 
154
191
  it("shows all detected values when multiple codes are scanned", async () => {
155
- const wrapper = await mountSuspended(CaptureQrCode);
192
+ const wrapper = await mountCapture();
156
193
  const vm = wrapper.vm as unknown as CaptureVM;
157
194
  vm.onDetect([{ rawValue: "https://one.com" }, { rawValue: "https://two.com" }]);
158
195
  await nextTick();
@@ -163,7 +200,7 @@ describe("CaptureQrCode", () => {
163
200
  });
164
201
 
165
202
  it("replaces results on subsequent scans", async () => {
166
- const wrapper = await mountSuspended(CaptureQrCode);
203
+ const wrapper = await mountCapture();
167
204
  const vm = wrapper.vm as unknown as CaptureVM;
168
205
  vm.onDetect([{ rawValue: "first" }]);
169
206
  await nextTick();
@@ -177,25 +214,69 @@ describe("CaptureQrCode", () => {
177
214
  // ─── Visibility change ────────────────────────────────────────────────────
178
215
 
179
216
  it("stops the camera when the page becomes hidden", async () => {
180
- const wrapper = await mountSuspended(CaptureQrCode);
217
+ const wrapper = await mountCapture();
218
+ expect(wrapper.find(".qrcode-stream-stub").exists()).toBe(true);
181
219
  Object.defineProperty(document, "hidden", { get: () => true, configurable: true });
182
220
  document.dispatchEvent(new Event("visibilitychange"));
183
221
  await nextTick();
184
- expect(wrapper.find("qrcodestream").exists()).toBe(false);
222
+ expect(wrapper.find(".qrcode-stream-stub").exists()).toBe(false);
185
223
  Object.defineProperty(document, "hidden", { get: () => false, configurable: true });
186
224
  });
187
225
 
226
+ it("turns the camera back on when the page becomes visible again", async () => {
227
+ const wrapper = await mountCapture();
228
+ const vm = wrapper.vm as unknown as CaptureVM;
229
+ Object.defineProperty(document, "hidden", { get: () => true, configurable: true });
230
+ document.dispatchEvent(new Event("visibilitychange"));
231
+ await nextTick();
232
+ expect(vm.state.cameraOn).toBe(false);
233
+
234
+ Object.defineProperty(document, "hidden", { get: () => false, configurable: true });
235
+ document.dispatchEvent(new Event("visibilitychange"));
236
+ await nextTick();
237
+ expect(vm.state.cameraOn).toBe(true);
238
+ });
239
+
240
+ it("stays in the error state when the page becomes visible again", async () => {
241
+ const wrapper = await mountCapture();
242
+ const vm = wrapper.vm as unknown as CaptureVM;
243
+ vm.onError(new Error("test"));
244
+ Object.defineProperty(document, "hidden", { get: () => true, configurable: true });
245
+ document.dispatchEvent(new Event("visibilitychange"));
246
+ Object.defineProperty(document, "hidden", { get: () => false, configurable: true });
247
+ document.dispatchEvent(new Event("visibilitychange"));
248
+ await nextTick();
249
+ expect(wrapper.find(".capture-qr-code-error").exists()).toBe(true);
250
+ });
251
+
252
+ it("only stops camera streams inside its own root", async () => {
253
+ const outside = document.createElement("video");
254
+ const stop = vi.fn();
255
+ Object.defineProperty(outside, "srcObject", {
256
+ value: { getTracks: () => [{ stop }] },
257
+ writable: true,
258
+ configurable: true,
259
+ });
260
+ document.body.appendChild(outside);
261
+
262
+ const wrapper = await mountCapture();
263
+ wrapper.unmount();
264
+ mounted.splice(mounted.indexOf(wrapper), 1);
265
+ expect(stop).not.toHaveBeenCalled();
266
+ outside.remove();
267
+ });
268
+
188
269
  // ─── styleClassPassthrough ────────────────────────────────────────────────
189
270
 
190
271
  it("applies a single styleClassPassthrough string", async () => {
191
- const wrapper = await mountSuspended(CaptureQrCode, {
272
+ const wrapper = await mountCapture({
192
273
  props: { styleClassPassthrough: "custom-class" },
193
274
  });
194
275
  expect(wrapper.classes()).toContain("custom-class");
195
276
  });
196
277
 
197
278
  it("applies multiple styleClassPassthrough classes from an array", async () => {
198
- const wrapper = await mountSuspended(CaptureQrCode, {
279
+ const wrapper = await mountCapture({
199
280
  props: { styleClassPassthrough: ["class-a", "class-b"] },
200
281
  });
201
282
  expect(wrapper.classes()).toContain("class-a");
@@ -203,7 +284,7 @@ describe("CaptureQrCode", () => {
203
284
  });
204
285
 
205
286
  it("retains capture-qr-code class alongside styleClassPassthrough", async () => {
206
- const wrapper = await mountSuspended(CaptureQrCode, {
287
+ const wrapper = await mountCapture({
207
288
  props: { styleClassPassthrough: "extra" },
208
289
  });
209
290
  expect(wrapper.classes()).toContain("capture-qr-code");
@@ -212,23 +293,15 @@ describe("CaptureQrCode", () => {
212
293
  });
213
294
 
214
295
  describe("CaptureQrCode labels and a11y", () => {
215
- beforeEach(() => {
216
- vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as unknown as RenderingContext);
217
- });
218
-
219
- afterEach(() => {
220
- vi.restoreAllMocks();
221
- });
222
-
223
296
  it("uses cameraStoppedLabel for the stopped message", async () => {
224
- const wrapper = await mountSuspended(CaptureQrCode, { props: { cameraStoppedLabel: "Caméra arrêtée" } });
297
+ const wrapper = await mountCapture({ props: { cameraStoppedLabel: "Caméra arrêtée" } });
225
298
  (wrapper.vm as unknown as CaptureVM).state.cameraOn = false;
226
299
  await nextTick();
227
300
  expect(wrapper.find(".capture-qr-code-stopped").text()).toBe("Caméra arrêtée");
228
301
  });
229
302
 
230
303
  it("uses resetCameraLabel for the reset button and marks the error as an alert", async () => {
231
- const wrapper = await mountSuspended(CaptureQrCode, { props: { resetCameraLabel: "Réinitialiser" } });
304
+ const wrapper = await mountCapture({ props: { resetCameraLabel: "Réinitialiser" } });
232
305
  (wrapper.vm as unknown as CaptureVM).onError(new Error("test"));
233
306
  await nextTick();
234
307
  expect(wrapper.find(".capture-qr-code-error").attributes("role")).toBe("alert");
@@ -236,7 +309,7 @@ describe("CaptureQrCode labels and a11y", () => {
236
309
  });
237
310
 
238
311
  it("lets the error slot replace the default error content", async () => {
239
- const wrapper = await mountSuspended(CaptureQrCode, {
312
+ const wrapper = await mountCapture({
240
313
  slots: { error: ({ error }: { error: string }) => `Custom: ${error}` },
241
314
  });
242
315
  const err = new Error("denied");
@@ -247,12 +320,12 @@ describe("CaptureQrCode labels and a11y", () => {
247
320
  });
248
321
 
249
322
  it("keeps a polite live region for results", async () => {
250
- const wrapper = await mountSuspended(CaptureQrCode);
323
+ const wrapper = await mountCapture();
251
324
  expect(wrapper.find(".capture-qr-code-results").attributes("aria-live")).toBe("polite");
252
325
  });
253
326
 
254
327
  it("updates classes when styleClassPassthrough changes after mount", async () => {
255
- const wrapper = await mountSuspended(CaptureQrCode, { props: { styleClassPassthrough: ["original"] } });
328
+ const wrapper = await mountCapture({ props: { styleClassPassthrough: ["original"] } });
256
329
  await wrapper.setProps({ styleClassPassthrough: ["updated"] });
257
330
  expect(wrapper.classes()).not.toContain("original");
258
331
  expect(wrapper.classes()).toContain("updated");
@@ -1,56 +1,70 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import { nextTick } from "vue";
4
4
  import DecodeQrCode from "../DecodeQrCode.vue";
5
5
 
6
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
7
+
8
+ const mountDecode = async (options: { props?: Record<string, unknown> } = {}) => {
9
+ const wrapper = await mountSuspended(DecodeQrCode, options);
10
+ mounted.push(wrapper);
11
+ return wrapper;
12
+ };
13
+
14
+ afterEach(() => {
15
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
16
+ vi.restoreAllMocks();
17
+ });
18
+
6
19
  interface DecodeVM {
7
20
  result: string[] | undefined;
8
21
  isDropping: boolean;
9
22
  onDetect: (codes: { rawValue: string }[]) => void;
10
23
  onDropping: (dropping: boolean) => void;
24
+ onDropError: () => void;
11
25
  }
12
26
 
13
27
  describe("DecodeQrCode", () => {
14
28
  // ─── Mount ───────────────────────────────────────────────────────────────
15
29
 
16
30
  it("mounts without error", async () => {
17
- const wrapper = await mountSuspended(DecodeQrCode);
31
+ const wrapper = await mountDecode();
18
32
  expect(wrapper.vm).toBeTruthy();
19
33
  });
20
34
 
21
35
  // ─── Root element ─────────────────────────────────────────────────────────
22
36
 
23
37
  it("renders as a div", async () => {
24
- const wrapper = await mountSuspended(DecodeQrCode);
38
+ const wrapper = await mountDecode();
25
39
  expect(wrapper.element.tagName).toBe("DIV");
26
40
  });
27
41
 
28
42
  it("always has the decode-qr-code class", async () => {
29
- const wrapper = await mountSuspended(DecodeQrCode);
43
+ const wrapper = await mountDecode();
30
44
  expect(wrapper.classes()).toContain("decode-qr-code");
31
45
  });
32
46
 
33
47
  // ─── Initial DOM structure ─────────────────────────────────────────────────
34
48
 
35
49
  it("renders the file capture element", async () => {
36
- const wrapper = await mountSuspended(DecodeQrCode);
50
+ const wrapper = await mountDecode();
37
51
  expect(wrapper.find(".decode-qr-code-capture").exists()).toBe(true);
38
52
  });
39
53
 
40
54
  it("renders the drop zone element", async () => {
41
- const wrapper = await mountSuspended(DecodeQrCode);
55
+ const wrapper = await mountDecode();
42
56
  expect(wrapper.find(".decode-qr-code-dropzone").exists()).toBe(true);
43
57
  });
44
58
 
45
59
  it("does not show scanned results initially", async () => {
46
- const wrapper = await mountSuspended(DecodeQrCode);
60
+ const wrapper = await mountDecode();
47
61
  expect(wrapper.find(".decode-qr-code-results ul").exists()).toBe(false);
48
62
  });
49
63
 
50
64
  // ─── Detect ───────────────────────────────────────────────────────────────
51
65
 
52
66
  it("shows scanned results after onDetect is called", async () => {
53
- const wrapper = await mountSuspended(DecodeQrCode);
67
+ const wrapper = await mountDecode();
54
68
  const vm = wrapper.vm as unknown as DecodeVM;
55
69
  vm.onDetect([{ rawValue: "https://example.com" }]);
56
70
  await nextTick();
@@ -58,7 +72,7 @@ describe("DecodeQrCode", () => {
58
72
  });
59
73
 
60
74
  it("shows the detected value in the results list", async () => {
61
- const wrapper = await mountSuspended(DecodeQrCode);
75
+ const wrapper = await mountDecode();
62
76
  const vm = wrapper.vm as unknown as DecodeVM;
63
77
  vm.onDetect([{ rawValue: "https://example.com" }]);
64
78
  await nextTick();
@@ -66,7 +80,7 @@ describe("DecodeQrCode", () => {
66
80
  });
67
81
 
68
82
  it("shows all detected values when multiple codes are decoded", async () => {
69
- const wrapper = await mountSuspended(DecodeQrCode);
83
+ const wrapper = await mountDecode();
70
84
  const vm = wrapper.vm as unknown as DecodeVM;
71
85
  vm.onDetect([{ rawValue: "https://one.com" }, { rawValue: "https://two.com" }]);
72
86
  await nextTick();
@@ -77,7 +91,7 @@ describe("DecodeQrCode", () => {
77
91
  });
78
92
 
79
93
  it("replaces results on subsequent detections", async () => {
80
- const wrapper = await mountSuspended(DecodeQrCode);
94
+ const wrapper = await mountDecode();
81
95
  const vm = wrapper.vm as unknown as DecodeVM;
82
96
  vm.onDetect([{ rawValue: "first" }]);
83
97
  await nextTick();
@@ -89,7 +103,7 @@ describe("DecodeQrCode", () => {
89
103
  });
90
104
 
91
105
  it("hides results when detecting an empty array", async () => {
92
- const wrapper = await mountSuspended(DecodeQrCode);
106
+ const wrapper = await mountDecode();
93
107
  const vm = wrapper.vm as unknown as DecodeVM;
94
108
  vm.onDetect([{ rawValue: "https://example.com" }]);
95
109
  await nextTick();
@@ -101,7 +115,7 @@ describe("DecodeQrCode", () => {
101
115
  // ─── Dropping state ───────────────────────────────────────────────────────
102
116
 
103
117
  it("sets isDropping to true when onDropping is called with true", async () => {
104
- const wrapper = await mountSuspended(DecodeQrCode);
118
+ const wrapper = await mountDecode();
105
119
  const vm = wrapper.vm as unknown as DecodeVM;
106
120
  vm.onDropping(true);
107
121
  await nextTick();
@@ -109,7 +123,7 @@ describe("DecodeQrCode", () => {
109
123
  });
110
124
 
111
125
  it("sets isDropping to false when onDropping is called with false", async () => {
112
- const wrapper = await mountSuspended(DecodeQrCode);
126
+ const wrapper = await mountDecode();
113
127
  const vm = wrapper.vm as unknown as DecodeVM;
114
128
  vm.onDropping(true);
115
129
  await nextTick();
@@ -121,14 +135,14 @@ describe("DecodeQrCode", () => {
121
135
  // ─── styleClassPassthrough ────────────────────────────────────────────────
122
136
 
123
137
  it("applies a single styleClassPassthrough string", async () => {
124
- const wrapper = await mountSuspended(DecodeQrCode, {
138
+ const wrapper = await mountDecode({
125
139
  props: { styleClassPassthrough: "custom-class" },
126
140
  });
127
141
  expect(wrapper.classes()).toContain("custom-class");
128
142
  });
129
143
 
130
144
  it("applies multiple styleClassPassthrough classes from an array", async () => {
131
- const wrapper = await mountSuspended(DecodeQrCode, {
145
+ const wrapper = await mountDecode({
132
146
  props: { styleClassPassthrough: ["class-a", "class-b"] },
133
147
  });
134
148
  expect(wrapper.classes()).toContain("class-a");
@@ -136,7 +150,7 @@ describe("DecodeQrCode", () => {
136
150
  });
137
151
 
138
152
  it("retains decode-qr-code class alongside styleClassPassthrough", async () => {
139
- const wrapper = await mountSuspended(DecodeQrCode, {
153
+ const wrapper = await mountDecode({
140
154
  props: { styleClassPassthrough: "extra" },
141
155
  });
142
156
  expect(wrapper.classes()).toContain("decode-qr-code");
@@ -146,34 +160,69 @@ describe("DecodeQrCode", () => {
146
160
 
147
161
  describe("DecodeQrCode labels, drop state and a11y", () => {
148
162
  it("wraps the file input in a label with the default upload text", async () => {
149
- const wrapper = await mountSuspended(DecodeQrCode);
163
+ const wrapper = await mountDecode();
150
164
  const label = wrapper.find("label.decode-qr-code-upload");
151
165
  expect(label.text()).toContain("Upload a QR code image");
152
166
  expect(label.find(".decode-qr-code-capture").exists()).toBe(true);
153
167
  });
154
168
 
155
169
  it("uses uploadLabel and dropLabel when given", async () => {
156
- const wrapper = await mountSuspended(DecodeQrCode, {
170
+ const wrapper = await mountDecode({
157
171
  props: { uploadLabel: "Téléverser", dropLabel: "Déposer ici" },
158
172
  });
159
173
  expect(wrapper.find(".decode-qr-code-upload-label").text()).toBe("Téléverser");
160
174
  expect(wrapper.find(".decode-qr-code-dropzone-label").text()).toBe("Déposer ici");
161
175
  });
162
176
 
163
- it("adds is-dropping to the drop zone while an image is dragged over it", async () => {
164
- const wrapper = await mountSuspended(DecodeQrCode);
165
- (wrapper.vm as unknown as DecodeVM).onDropping(true);
177
+ it("sets data-dropping on the drop zone only while an image is dragged over it", async () => {
178
+ const wrapper = await mountDecode();
179
+ const vm = wrapper.vm as unknown as DecodeVM;
180
+ expect(wrapper.find(".decode-qr-code-dropzone").attributes("data-dropping")).toBeUndefined();
181
+ vm.onDropping(true);
182
+ await nextTick();
183
+ expect(wrapper.find(".decode-qr-code-dropzone").attributes("data-dropping")).toBe("");
184
+ vm.onDropping(false);
185
+ await nextTick();
186
+ expect(wrapper.find(".decode-qr-code-dropzone").attributes("data-dropping")).toBeUndefined();
187
+ });
188
+
189
+ it("says no code was found when an image decodes to nothing", async () => {
190
+ const wrapper = await mountDecode({ props: { noResultLabel: "Aucun code" } });
191
+ expect(wrapper.find(".decode-qr-code-status").exists()).toBe(false);
192
+ (wrapper.vm as unknown as DecodeVM).onDetect([]);
193
+ await nextTick();
194
+ expect(wrapper.find(".decode-qr-code-status").text()).toBe("Aucun code");
195
+ });
196
+
197
+ it("says the file couldn't be read when the drop zone reports an error, and clears old results", async () => {
198
+ const wrapper = await mountDecode();
199
+ const vm = wrapper.vm as unknown as DecodeVM;
200
+ vm.onDetect([{ rawValue: "https://example.com" }]);
201
+ await nextTick();
202
+ vm.onDropError();
203
+ await nextTick();
204
+ expect(wrapper.find(".decode-qr-code-results ul").exists()).toBe(false);
205
+ expect(wrapper.find(".decode-qr-code-status").text()).toBe("That file couldn't be read as an image.");
206
+ });
207
+
208
+ it("replaces a status message with results on the next successful decode", async () => {
209
+ const wrapper = await mountDecode();
210
+ const vm = wrapper.vm as unknown as DecodeVM;
211
+ vm.onDetect([]);
212
+ await nextTick();
213
+ vm.onDetect([{ rawValue: "found" }]);
166
214
  await nextTick();
167
- expect(wrapper.find(".decode-qr-code-dropzone").classes()).toContain("is-dropping");
215
+ expect(wrapper.find(".decode-qr-code-status").exists()).toBe(false);
216
+ expect(wrapper.find(".decode-qr-code-result").text()).toBe("found");
168
217
  });
169
218
 
170
219
  it("keeps a polite live region for results", async () => {
171
- const wrapper = await mountSuspended(DecodeQrCode);
220
+ const wrapper = await mountDecode();
172
221
  expect(wrapper.find(".decode-qr-code-results").attributes("aria-live")).toBe("polite");
173
222
  });
174
223
 
175
224
  it("updates classes when styleClassPassthrough changes after mount", async () => {
176
- const wrapper = await mountSuspended(DecodeQrCode, { props: { styleClassPassthrough: ["original"] } });
225
+ const wrapper = await mountDecode({ props: { styleClassPassthrough: ["original"] } });
177
226
  await wrapper.setProps({ styleClassPassthrough: ["updated"] });
178
227
  expect(wrapper.classes()).not.toContain("original");
179
228
  expect(wrapper.classes()).toContain("updated");
@@ -14,13 +14,15 @@ class set and one token API, so everything here applies to both.
14
14
  | `--samaritan-prompt-font-size` | `2rem` | Text size (the underline and gaps don't scale with it) |
15
15
  | `--samaritan-prompt-letter-spacing` | `0.08em` | Letter spacing |
16
16
  | `--samaritan-prompt-text-transform` | `uppercase` | Text case (`none` keeps messages as written) |
17
- | `--samaritan-prompt-text-colour` | `#ffffff` | Text colour |
17
+ | `--samaritan-prompt-text-colour` | `var(--theme-text)` | Text colour |
18
+ | `--samaritan-prompt-text-white-space` | `normal` | `nowrap` keeps each message on one line (it can then overflow a narrow container) |
19
+ | `--samaritan-prompt-text-line-clamp` | `none` | Lines before an ellipsis. `1` is single-line ellipsis, `none` shows everything |
18
20
 
19
21
  ### Underline
20
22
 
21
23
  | Token | Default | Controls |
22
24
  |---|---|---|
23
- | `--samaritan-prompt-underline-colour` | `#ffffff` | Underline colour |
25
+ | `--samaritan-prompt-underline-colour` | `var(--theme-text)` | Underline colour |
24
26
  | `--samaritan-prompt-underline-height` | `0.15rem` | Underline thickness |
25
27
  | `--samaritan-prompt-underline-gap` | `0.6rem` | Space between text and underline |
26
28
 
@@ -28,7 +30,7 @@ class set and one token API, so everything here applies to both.
28
30
 
29
31
  | Token | Default | Controls |
30
32
  |---|---|---|
31
- | `--samaritan-prompt-cursor-colour` | `#cc0000` | Cursor colour at the peak of the pulse |
33
+ | `--samaritan-prompt-cursor-colour` | `var(--theme-accent)` | Cursor colour at the peak of the pulse |
32
34
  | `--samaritan-prompt-cursor-colour-off` | `transparent` | Cursor colour at the trough of the pulse |
33
35
  | `--samaritan-prompt-cursor-size` | `2.4rem` | Cursor glyph size |
34
36
  | `--samaritan-prompt-cursor-pulse-duration` | `2.5s` | One full pulse cycle |
@@ -37,6 +39,12 @@ class set and one token API, so everything here applies to both.
37
39
  Private, not public API: `--_fade-duration` (set inline on the root from the `fadeDuration` prop,
38
40
  or the current message's `fadeDuration` in `SamaritanPromptMixed`).
39
41
 
42
+ > **Changed 2026-10-21**: colour defaults follow the theme (`--theme-text` for text and underline,
43
+ > `--theme-accent` for the cursor) instead of assuming a dark page (`#ffffff`, `#cc0000`); set the
44
+ > three colour tokens for the classic white-on-black look. Text now wraps (`overflow-wrap: anywhere`)
45
+ > instead of `nowrap`, so a long message stays inside its container; `--samaritan-prompt-text-white-space`
46
+ > and `--samaritan-prompt-text-line-clamp` are new.
47
+ >
40
48
  > **Changed 2026-09-27**: all tokens were renamed from `--samaritan-*` to `--samaritan-prompt-*`,
41
49
  > and `color` became `colour` (`--samaritan-color-text` is now `--samaritan-prompt-text-colour`,
42
50
  > `--samaritan-color-cursor-off` is now `--samaritan-prompt-cursor-colour-off`, and so on). The
@@ -69,20 +77,22 @@ Inner element classes, safe to target:
69
77
 
70
78
  - The cursor pulses between `--samaritan-prompt-cursor-colour` and `-colour-off`; set both to the
71
79
  same value for a steady cursor.
72
- - `prefers-reduced-motion: reduce` removes the cursor pulse and the word-pulse fade transition.
73
- The text sequence itself keeps running (it is the content), and pauses on hover like always.
80
+ - `prefers-reduced-motion: reduce` removes the cursor pulse and the word-pulse fade transition, and
81
+ the typewriter shows each message whole instead of typing and deleting it character by
82
+ character. The message sequence itself keeps running (it is the content), and pauses on hover
83
+ like always.
74
84
 
75
85
  ---
76
86
 
77
87
  ## Global theming
78
88
 
79
- The defaults assume a dark page. For a site that switches colour scheme, point the colours at the
80
- theme tokens once:
89
+ The colours follow the theme by default. For the classic white-on-black look across a site:
81
90
 
82
91
  ```css
83
92
  :where(html) {
84
- --samaritan-prompt-text-colour: var(--theme-text);
85
- --samaritan-prompt-underline-colour: var(--theme-text);
93
+ --samaritan-prompt-text-colour: #fff;
94
+ --samaritan-prompt-underline-colour: #fff;
95
+ --samaritan-prompt-cursor-colour: #cc0000;
86
96
  }
87
97
  ```
88
98
 
@@ -115,4 +125,6 @@ element. Reactive: changing the prop replaces the previous classes.
115
125
  > that ancestor. Its styles now apply wherever it's used, and `.samaritan-stage` means nothing
116
126
  > to the library.
117
127
 
118
- - The `Mono MMM 5` font loads from `/fonts/monoMMM_5.ttf`, shipped in this layer's `public/`.
128
+ - The `Mono MMM 5` font loads from `/fonts/monoMMM_5.ttf`, shipped in this layer's `public/`. Both components
129
+ declare it with `font-display: swap` (until 2026-10-21 `SamaritanPromptMixed` used `optional`, so
130
+ which one won depended on load order).