srcdev-nuxt-components 9.6.9 → 9.6.11

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 (109) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +4 -2
  2. package/.claude/skills/component-compliance-checklist.md +24 -2
  3. package/.claude/skills/component-inline-action-button.md +5 -4
  4. package/.claude/skills/component-naming.md +8 -2
  5. package/.claude/skills/components/{alert-mask-core.md → alert-mask.md} +38 -23
  6. package/.claude/skills/components/alert-masked-content.md +3 -3
  7. package/.claude/skills/components/container-glow.md +1 -1
  8. package/.claude/skills/components/display-theme-switch.md +8 -8
  9. package/.claude/skills/components/input-button.md +26 -7
  10. package/.claude/skills/components/input-checkbox-radio.md +2 -0
  11. package/.claude/skills/components/input-description.md +5 -0
  12. package/.claude/skills/components/input-error.md +9 -2
  13. package/.claude/skills/components/input-label.md +8 -2
  14. package/.claude/skills/components/input-number.md +2 -0
  15. package/.claude/skills/components/input-otp.md +2 -0
  16. package/.claude/skills/components/input-range.md +2 -0
  17. package/.claude/skills/components/input-select.md +2 -0
  18. package/.claude/skills/components/input-text.md +2 -0
  19. package/.claude/skills/components/input-textarea.md +2 -0
  20. package/.claude/skills/components/pending-effect.md +5 -4
  21. package/.claude/skills/components/{triple-toggle-switch.md → segmented-control.md} +34 -19
  22. package/.claude/skills/components/services-card.md +1 -1
  23. package/.claude/skills/components/tabbed-content.md +1 -1
  24. package/.claude/skills/components/toggle-switch.md +2 -0
  25. package/.claude/skills/css-nesting-conventions.md +22 -1
  26. package/.claude/skills/index.md +5 -5
  27. package/.claude/skills/storybook-add-story.md +3 -1
  28. package/.claude/skills/theming-component-token-pattern.md +24 -0
  29. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  30. package/.claude/skills/theming-partial-override.md +1 -1
  31. package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
  32. package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
  33. package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
  34. package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
  35. package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
  36. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
  37. package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
  38. package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
  39. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
  40. package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
  41. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
  42. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
  43. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
  44. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
  45. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
  46. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
  47. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
  48. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
  49. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
  50. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
  51. package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
  52. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
  53. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
  54. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
  55. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
  56. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
  57. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
  58. package/app/components/05.forms/form-errors/InputError.vue +43 -37
  59. package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
  60. package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
  61. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
  62. package/app/components/05.forms/input-button/InputButton.vue +52 -100
  63. package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
  64. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
  65. package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
  66. package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
  67. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
  68. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
  69. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
  70. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
  71. package/app/components/05.forms/input-description/InputDescription.vue +2 -1
  72. package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
  73. package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
  74. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +10 -2
  75. package/app/components/05.forms/input-label/InputLabel.vue +1 -0
  76. package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
  77. package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
  78. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
  79. package/app/components/05.forms/input-number/InputNumber.vue +4 -5
  80. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
  81. package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
  82. package/app/components/05.forms/input-range/InputRange.vue +1 -1
  83. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
  84. package/app/components/05.forms/input-select/InputSelect.vue +14 -5
  85. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
  86. package/app/components/05.forms/input-text/InputText.vue +4 -4
  87. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
  88. package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
  89. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
  90. package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
  91. package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
  92. package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
  93. package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
  94. package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
  95. package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
  96. package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
  97. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
  98. package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
  99. package/app/types/components/index.ts +1 -1
  100. package/package.json +1 -1
  101. package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
  102. package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
  103. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
  104. package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
  105. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
  106. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
  107. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
  108. package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
  109. /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
@@ -1,3 +1,6 @@
1
+ import { ref } from "vue";
2
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
3
+ import type { MediaCanvas } from "~/types/components";
1
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
2
5
  import AlertMaskedContent from "../AlertMaskedContent.vue";
3
6
  import AlertContent from "../../alert-content/AlertContent.vue";
@@ -211,3 +214,60 @@ export const TokenOverrides: Story = {
211
214
  `,
212
215
  }),
213
216
  };
217
+
218
+ export const StressTest: Story = {
219
+ name: "Stress Test (Worst-Case Data)",
220
+ args: { theme: "warning", dismissible: true },
221
+ decorators: [
222
+ (story, context) => ({
223
+ components: { story, CanvasSwitcher },
224
+ setup() {
225
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
226
+ return { canvasName };
227
+ },
228
+ template: `
229
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
230
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
231
+ </div>
232
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
233
+ <story />
234
+ </div>
235
+ `,
236
+ }),
237
+ ],
238
+ render: (args) => ({
239
+ components: { AlertMaskedContent },
240
+ setup() {
241
+ return { args, wrapperStyle };
242
+ },
243
+ template: `
244
+ <div :data-theme="args.theme" :style="wrapperStyle" style="display: grid; gap: 1.6rem; max-width: none;">
245
+ <AlertMaskedContent v-bind="args">
246
+ <template #title>⚠️ Datenschutzgrundverordnungskonformitätsbestätigungsaufforderung &lt;b&gt;not bold&lt;/b&gt;</template>
247
+ <template #content>
248
+ Wir verwenden Ihre Adresse ausschließlich für Rückfragen zu Ihrer Bestellung bei Alex Morgan in Northtown.
249
+ https://example.com/a/very/long/unbroken/url/that/should/wrap/inside/the/mask يرجى المحاولة مرة أخرى
250
+ </template>
251
+ </AlertMaskedContent>
252
+ <div dir="rtl">
253
+ <AlertMaskedContent v-bind="args">
254
+ <template #title>تنبيه</template>
255
+ <template #content>نص من اليمين إلى اليسار؛ يبقى شريط التمييز على اليسار والنص بعيدًا عنه.</template>
256
+ </AlertMaskedContent>
257
+ </div>
258
+ <AlertMaskedContent v-bind="args" :mask-config="{ radiusLeft: 500, radiusRight: 500, borderLeft: -4 }">
259
+ <template #title>x</template>
260
+ </AlertMaskedContent>
261
+ </div>
262
+ `,
263
+ }),
264
+ parameters: {
265
+ docs: {
266
+ story: { inline: false, height: "900px" },
267
+ description: {
268
+ story:
269
+ "Long German title and body with an unbroken compound word and URL, HTML-like and right-to-left text, a right-to-left alert, and a hostile `mask-config` (oversized radii, negative border) round a one-character title. Check at every canvas width: text wraps inside the mask, the accent bar stays on the left with the text clear of it, and the hostile mask still draws a clean shape.",
270
+ },
271
+ },
272
+ },
273
+ };
@@ -38,16 +38,16 @@ describe("AlertMaskedContent", () => {
38
38
  expect(wrapper.find(".alert-masked-content").attributes("data-theme")).toBe("success");
39
39
  });
40
40
 
41
- // ─── AlertMaskCore ────────────────────────────────────────────────────────
41
+ // ─── AlertMask ────────────────────────────────────────────────────────
42
42
 
43
- it("renders AlertMaskCore", async () => {
43
+ it("renders AlertMask", async () => {
44
44
  const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } });
45
- expect(wrapper.find(".alert-mask-core").exists()).toBe(true);
45
+ expect(wrapper.find(".alert-mask").exists()).toBe(true);
46
46
  });
47
47
 
48
- it("renders AlertContentInner inside AlertMaskCore", async () => {
48
+ it("renders AlertContentInner inside AlertMask", async () => {
49
49
  const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } });
50
- expect(wrapper.find(".alert-mask-core .alert-content-inner").exists()).toBe(true);
50
+ expect(wrapper.find(".alert-mask .alert-content-inner").exists()).toBe(true);
51
51
  });
52
52
 
53
53
  // ─── Icon ─────────────────────────────────────────────────────────────────
@@ -131,15 +131,15 @@ describe("AlertMaskedContent", () => {
131
131
 
132
132
  // ─── Default maskConfig ───────────────────────────────────────────────────
133
133
 
134
- it("passes the resolved maskConfig to AlertMaskCore", async () => {
134
+ it("passes the resolved maskConfig to AlertMask", async () => {
135
135
  const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } });
136
136
  const contentEl = wrapper.find(".alert-mask-content");
137
137
  const style = (contentEl.element as HTMLElement).style;
138
- // Default borderLeft: 6 → --_inset-inline-start: 6px
139
- expect(style.getPropertyValue("--_inset-inline-start")).toBe("6px");
140
- expect(style.getPropertyValue("--_inset-block-start")).toBe("1px");
141
- expect(style.getPropertyValue("--_inset-inline-end")).toBe("1px");
142
- expect(style.getPropertyValue("--_inset-block-end")).toBe("1px");
138
+ // Default borderLeft: 6 → --_inset-left: 6px
139
+ expect(style.getPropertyValue("--_inset-left")).toBe("6px");
140
+ expect(style.getPropertyValue("--_inset-top")).toBe("1px");
141
+ expect(style.getPropertyValue("--_inset-right")).toBe("1px");
142
+ expect(style.getPropertyValue("--_inset-bottom")).toBe("1px");
143
143
  });
144
144
 
145
145
  it("routes the default mask colours through public tokens", async () => {
@@ -168,7 +168,7 @@ describe("AlertMaskedContent", () => {
168
168
  });
169
169
  const contentEl = wrapper.find(".alert-mask-content");
170
170
  const style = (contentEl.element as HTMLElement).style;
171
- expect(style.getPropertyValue("--_inset-inline-start")).toBe("12px");
171
+ expect(style.getPropertyValue("--_inset-left")).toBe("12px");
172
172
  });
173
173
 
174
174
  // ─── showIcon / actions (added 2026-09-27) ──────────────────────────────────
@@ -9,7 +9,7 @@ adds: position, motion and the two buttons.
9
9
  > **Changed 2026-10-15**: the closed state class `.closed` is now `.cookie-consent-banner-closed`,
10
10
  > and the buttons are `InputButton`s (`variant="inline"`, so the banner's tokens still style them):
11
11
  > each button is `.cookie-consent-banner-accept.input-button` / `.cookie-consent-banner-reject.input-button`
12
- > with the label in an inner `.button-text`. The button tokens below are unchanged.
12
+ > with the label in an inner `.input-button-text` (`.button-text` until 2026-10-17). The button tokens below are unchanged.
13
13
  >
14
14
  > **Changed 2026-09-27**: rebuilt on `AlertContent`. The banner's own panel tokens
15
15
  > (`--cookie-consent-banner-padding`, `-gap`, `-border-radius`, `-border`, `-accent-border-width`,
@@ -47,7 +47,7 @@ adds: position, motion and the two buttons.
47
47
 
48
48
  The gap between the buttons and their alignment (right by default) are `AlertContent`'s
49
49
  `--alert-content-actions-gap` and `--alert-content-actions-justify`. Long labels wrap inside the
50
- buttons (`InputButton`'s `.button-text`), and the buttons wrap onto their own lines when the row is full.
50
+ buttons (`InputButton`'s `.input-button-text`), and the buttons wrap onto their own lines when the row is full.
51
51
  The label keeps the banner's font size and case: `InputButton`'s `--button-font-size` and
52
52
  `--button-text-transform` don't apply here.
53
53
 
@@ -63,7 +63,7 @@ each resolving a public token once for reuse.
63
63
  - Classes: `.cookie-consent-banner` > `.cookie-consent-banner-inner` (the `role="region"`) >
64
64
  `.alert-content` (see its guide for the inner structure); the buttons are
65
65
  `InputButton`s with `.cookie-consent-banner-reject` and `.cookie-consent-banner-accept` (plus
66
- `.input-button.inline`) inside `.alert-content-actions`.
66
+ `.input-button[data-variant="inline"]`) inside `.alert-content-actions`.
67
67
  - `data-test-id`: `cookie-consent-banner`, `cookie-consent-banner-reject`, `cookie-consent-banner-accept`,
68
68
  plus `AlertContent`'s `alert-icon`, `alert-title`, `alert-content`, `alert-actions`.
69
69
 
@@ -127,7 +127,7 @@ const isClosed = computed(() => status.value !== "unset");
127
127
  outline-offset: 0.2rem;
128
128
  }
129
129
 
130
- .button-text {
130
+ & .input-button-text {
131
131
  font-size: inherit;
132
132
  text-transform: none;
133
133
  }
@@ -118,7 +118,7 @@ describe("CookieConsentBanner", () => {
118
118
  expect(button.tagName).toBe("BUTTON");
119
119
  expect(button.getAttribute("type")).toBe("button");
120
120
  expect(button.classList).toContain("input-button");
121
- expect(button.classList).toContain("inline");
121
+ expect(button.dataset.variant).toBe("inline");
122
122
  }
123
123
  });
124
124
 
@@ -1,11 +1,11 @@
1
1
  # DisplayThemeSwitch — Consumer Styling Guide
2
2
 
3
- ## No tokens of its own — delegates entirely to TripleToggleSwitch
3
+ ## No tokens of its own — delegates entirely to SegmentedControl
4
4
 
5
- `DisplayThemeSwitch` is a thin wrapper around `TripleToggleSwitch` (see
6
- `app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md`) and declares no
5
+ `DisplayThemeSwitch` is a thin wrapper around `SegmentedControl` (see
6
+ `app/components/05.forms/segmented-control/CONSUMER-STYLING.md`) and declares no
7
7
  `--display-theme-switch-*` tokens of its own. Every colour token documented there
8
- (`--triple-toggle-switch-surface`, `--triple-toggle-switch-border`, `--triple-toggle-switch-marker-surface`,
8
+ (`--segmented-control-surface`, `--segmented-control-border`, `--segmented-control-marker-surface`,
9
9
  etc.) applies unchanged, since `DisplayThemeSwitch` renders that component directly with no
10
10
  intermediate styling layer.
11
11
 
@@ -20,14 +20,14 @@ tight header/nav slot:
20
20
  <DisplayThemeSwitch style-class-passthrough="small" />
21
21
  ```
22
22
 
23
- This sets `TripleToggleSwitch`'s public `--triple-toggle-switch-gap`/`-padding`/
23
+ This sets `SegmentedControl`'s public `--segmented-control-gap`/`-padding`/
24
24
  `-option-padding`/`-icon-size` tokens to smaller values (`0.2rem`/`0.2rem`/`0.2rem`/`1.6rem` vs the
25
- default `0.4rem`/`0.4rem`/`0.4rem`/`2rem` — both already more compact than `TripleToggleSwitch`'s
25
+ default `0.4rem`/`0.4rem`/`0.4rem`/`2rem` — both already more compact than `SegmentedControl`'s
26
26
  own defaults of `1rem`/`0.6rem`/`0.5rem`/`2rem`). Override any of the four directly for a custom
27
27
  size instead of using `small`:
28
28
 
29
29
  ```vue
30
- <DisplayThemeSwitch style="--triple-toggle-switch-icon-size: 2.4rem;" />
30
+ <DisplayThemeSwitch style="--segmented-control-icon-size: 2.4rem;" />
31
31
  ```
32
32
 
33
33
  ---
@@ -40,8 +40,8 @@ the block **unlayered** (no `@layer` wrapper) so it beats the library's `@layer
40
40
  own file uses `<style scoped>`, selectors that reach inside the component need `:deep()`. Patterns
41
41
  and examples: `.claude/skills/component-local-style-override.md`.
42
42
 
43
- **Caveat:** the colour tokens (`--triple-toggle-switch-surface`, `-border`, ...) work from any
44
- ancestor, but the four sizing tokens (`--triple-toggle-switch-gap`, `-padding`, `-option-padding`,
43
+ **Caveat:** the colour tokens (`--segmented-control-surface`, `-border`, ...) work from any
44
+ ancestor, but the four sizing tokens (`--segmented-control-gap`, `-padding`, `-option-padding`,
45
45
  `-icon-size`) are declared on this component's own root (that's how its default and `small` sizes
46
46
  work), so an ancestor value never reaches the toggle. Set those on the root itself: a class from
47
47
  `style-class-passthrough`, or inline `style` as shown above.
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <ClientOnly>
3
- <TripleToggleSwitch
3
+ <SegmentedControl
4
4
  v-model="colorModeVal"
5
5
  v-model:field-data="sampleFieldData"
6
6
  :aria-label="ariaLabel"
@@ -77,27 +77,27 @@ watch(colorModeVal, (val) => {
77
77
  .colour-scheme-select {
78
78
  /* Everything DisplayThemeSwitch used to redeclare here beyond sizing (wrapper background/border/
79
79
  focus ring, marker border, option border/outline/focus/hover, option-icon colours, and the
80
- system/light/dark marker gradients) was a byte-for-byte duplicate of TripleToggleSwitch's
80
+ system/light/dark marker gradients) was a byte-for-byte duplicate of SegmentedControl's
81
81
  own defaults for the exact same selectors — a no-op at best, and for the ones that bypassed
82
- TripleToggleSwitch's public --triple-toggle-switch-* tokens with a hardcoded literal
82
+ SegmentedControl's public --segmented-control-* tokens with a hardcoded literal
83
83
  (background-color, border, focus outline, marker border, option border/hover/focus, icon
84
- colour) an active bug: a consumer setting e.g. --triple-toggle-switch-surface globally would
84
+ colour) an active bug: a consumer setting e.g. --segmented-control-surface globally would
85
85
  have been silently overridden back to the hardcoded default by this block's higher
86
- specificity. Removed 2026-09-20 — TripleToggleSwitch already owns all of that. Only the
87
- genuine "small" sizing variant remains, now driving TripleToggleSwitch's own public
86
+ specificity. Removed 2026-09-20 — SegmentedControl already owns all of that. Only the
87
+ genuine "small" sizing variant remains, now driving SegmentedControl's own public
88
88
  sizing tokens instead of reaching into its private --_form-* and --_scheme-icon-font-size
89
- locals directly (see TripleToggleSwitch's CONSUMER-STYLING.md). */
90
- &.triple-toggle-switch {
91
- --triple-toggle-switch-gap: 0.4rem;
92
- --triple-toggle-switch-padding: 0.4rem;
93
- --triple-toggle-switch-option-padding: 0.4rem;
94
- --triple-toggle-switch-icon-size: 2rem;
89
+ locals directly (see SegmentedControl's CONSUMER-STYLING.md). */
90
+ &.segmented-control {
91
+ --segmented-control-gap: 0.4rem;
92
+ --segmented-control-padding: 0.4rem;
93
+ --segmented-control-option-padding: 0.4rem;
94
+ --segmented-control-icon-size: 2rem;
95
95
 
96
96
  &.small {
97
- --triple-toggle-switch-gap: 0.2rem;
98
- --triple-toggle-switch-padding: 0.2rem;
99
- --triple-toggle-switch-option-padding: 0.2rem;
100
- --triple-toggle-switch-icon-size: 1.6rem;
97
+ --segmented-control-gap: 0.2rem;
98
+ --segmented-control-padding: 0.2rem;
99
+ --segmented-control-option-padding: 0.2rem;
100
+ --segmented-control-icon-size: 1.6rem;
101
101
  }
102
102
  }
103
103
  }
@@ -1,3 +1,6 @@
1
+ import { ref } from "vue";
2
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
3
+ import type { MediaCanvas } from "~/types/components";
1
4
  import DisplayThemeSwitch from "../DisplayThemeSwitch.vue";
2
5
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
6
 
@@ -50,7 +53,7 @@ const meta: Meta<typeof DisplayThemeSwitch> = {
50
53
  docs: {
51
54
  description: {
52
55
  component:
53
- "A system/light/dark theme switch wrapping TripleToggleSwitch, wired to useSettingsStore. Colour and sizing are entirely delegated to TripleToggleSwitch's public --triple-toggle-switch-* tokens — see CONSUMER-STYLING.md.",
56
+ "A system/light/dark theme switch wrapping SegmentedControl, wired to useSettingsStore. Colour and sizing are entirely delegated to SegmentedControl's public --segmented-control-* tokens — see CONSUMER-STYLING.md.",
54
57
  },
55
58
  },
56
59
  },
@@ -118,3 +121,52 @@ export const InNavigation: Story = {
118
121
  },
119
122
  },
120
123
  };
124
+
125
+ export const StressTest: Story = {
126
+ name: "Stress Test (Worst-Case Data)",
127
+ args: {
128
+ ariaLabel: "Farbschema für die gesamte Website auswählen <b>not bold</b>",
129
+ systemLabel: "Systemeinstellung des Betriebssystems übernehmen",
130
+ lightLabel: "🌞",
131
+ darkLabel: "داكن",
132
+ lightIcon: "",
133
+ },
134
+ decorators: [
135
+ (story, context) => ({
136
+ components: { story, CanvasSwitcher },
137
+ setup() {
138
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
139
+ return { canvasName };
140
+ },
141
+ template: `
142
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
143
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
144
+ </div>
145
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
146
+ <story />
147
+ </div>
148
+ `,
149
+ }),
150
+ ],
151
+ render: (args) => ({
152
+ components: { DisplayThemeSwitch },
153
+ setup() {
154
+ return { args };
155
+ },
156
+ template: `
157
+ <div style="display: grid; gap: 1.6rem; justify-items: start;">
158
+ <DisplayThemeSwitch v-bind="args" />
159
+ <DisplayThemeSwitch v-bind="args" style-class-passthrough="small" />
160
+ <div dir="rtl"><DisplayThemeSwitch v-bind="args" /></div>
161
+ </div>
162
+ `,
163
+ }),
164
+ parameters: {
165
+ docs: {
166
+ description: {
167
+ story:
168
+ "Long, HTML-like, emoji and right-to-left labels (screen-reader text only), an empty light icon, the small size and a right-to-left container, all bound to the one site colour scheme. Check at every canvas width: the switch stays inside the dashed outline, the empty-icon option still renders as a clickable circle, the marker follows the scheme in every copy, and it slides the right way in right-to-left.",
169
+ },
170
+ },
171
+ },
172
+ };
@@ -25,17 +25,17 @@ describe("DisplayThemeSwitch", () => {
25
25
  expect(wrapper.vm).toBeTruthy();
26
26
  });
27
27
 
28
- it("renders TripleToggleSwitch with the colour-scheme-select class", async () => {
28
+ it("renders SegmentedControl with the colour-scheme-select class", async () => {
29
29
  const wrapper = await mountSuspended(DisplayThemeSwitch);
30
30
  await nextTick();
31
31
  expect(wrapper.find(".colour-scheme-select").exists()).toBe(true);
32
- expect(wrapper.find(".triple-toggle-switch").exists()).toBe(true);
32
+ expect(wrapper.find(".segmented-control").exists()).toBe(true);
33
33
  });
34
34
 
35
35
  it("renders default option labels", async () => {
36
36
  const wrapper = await mountSuspended(DisplayThemeSwitch);
37
37
  await nextTick();
38
- const labels = wrapper.findAll(".option-group .sr-only").map((el) => el.text());
38
+ const labels = wrapper.findAll(".segmented-control-option .sr-only").map((el) => el.text());
39
39
  expect(labels).toEqual(["System", "Light", "Dark"]);
40
40
  });
41
41
 
@@ -48,7 +48,7 @@ describe("DisplayThemeSwitch", () => {
48
48
  },
49
49
  });
50
50
  await nextTick();
51
- const labels = wrapper.findAll(".option-group .sr-only").map((el) => el.text());
51
+ const labels = wrapper.findAll(".segmented-control-option .sr-only").map((el) => el.text());
52
52
  expect(labels).toEqual(["Auto", "Day", "Night"]);
53
53
  });
54
54
 
@@ -61,8 +61,8 @@ describe("DisplayThemeSwitch", () => {
61
61
  },
62
62
  });
63
63
  await nextTick();
64
- const icons = wrapper.findAll(".option-icon").map((el) => el.attributes("class"));
65
- expect(icons.some((c) => c?.includes("system"))).toBe(true);
64
+ const icons = wrapper.findAll(".segmented-control-option-icon").map((el) => el.html());
65
+ expect(icons.some((html) => html.includes("custom:system") || html.includes("custom-system"))).toBe(true);
66
66
  });
67
67
 
68
68
  it("names the radiogroup with ariaLabel", async () => {
@@ -79,14 +79,14 @@ describe("DisplayThemeSwitch", () => {
79
79
  props: { styleClassPassthrough: "small" },
80
80
  });
81
81
  await nextTick();
82
- expect(wrapper.find(".triple-toggle-switch").classes()).toContain("small");
82
+ expect(wrapper.find(".segmented-control").classes()).toContain("small");
83
83
  });
84
84
 
85
85
  it("calls setColourScheme when the selected option changes", async () => {
86
86
  const wrapper = await mountSuspended(DisplayThemeSwitch);
87
87
  await nextTick();
88
88
 
89
- const lightInput = wrapper.findAll("input.option-input")[1];
89
+ const lightInput = wrapper.findAll("input.segmented-control-option-input")[1];
90
90
  await lightInput?.setValue(true);
91
91
  await nextTick();
92
92
 
@@ -17,7 +17,7 @@ style it rather than `InputButton`'s variant colours), with optional description
17
17
  > `--input-copy-gap`, `--input-copy-focus-ring`, `--input-copy-description-line-clamp`.
18
18
  > - The input's focus outline used the border-radius token as its colour; it's now a real
19
19
  > `:focus-visible` ring from `--input-copy-focus-ring`.
20
- > - The button label is `InputButton`'s `.button-text` (was `.input-copy__button-text`), and it
20
+ > - The button label is `InputButton`'s `.input-button-text` (was `.input-copy__button-text`, then `.button-text` until 2026-10-17), and it
21
21
  > hides below 48rem of the **component's** width (a container query), not a 480px viewport.
22
22
 
23
23
  ## Public token API
@@ -70,10 +70,10 @@ shared by the input and the button.
70
70
  `copiedIcon`.
71
71
  - `disabled` on the button when `isDisabled` is set.
72
72
  - Classes: `.input-copy` > `.input-copy__wrapper` > `.input-copy__input` and
73
- `.input-copy__button` (also `.input-button.inline`, containing `.input-copy__icon` and
74
- `.button-text`); then `.input-copy__description` and a visually hidden `role="status"` span.
73
+ `.input-copy__button` (also `.input-button[data-variant="inline"]`, containing `.input-copy__icon` and
74
+ `.input-button-text`); then `.input-copy__description` and a visually hidden `role="status"` span.
75
75
  - Container: the root is `container: input-copy / inline-size`. Below `48rem` of its own width the
76
- button's `.button-text` is hidden and only the icon shows (the button keeps its aria-label).
76
+ button's `.input-button-text` is hidden and only the icon shows (the button keeps its aria-label).
77
77
 
78
78
  ## Global theming
79
79
 
@@ -199,14 +199,14 @@ onUnmounted(() => {
199
199
  flex-shrink: 0;
200
200
  }
201
201
 
202
- .button-text {
202
+ & .input-button-text {
203
203
  font-size: inherit;
204
204
  text-transform: none;
205
205
  }
206
206
  }
207
207
 
208
208
  @container input-copy (width < 48rem) {
209
- .input-copy__button.input-button .button-text {
209
+ .input-copy__button.input-button .input-button-text {
210
210
  display: none;
211
211
  }
212
212
  }
@@ -56,7 +56,7 @@ describe("InputCopy", () => {
56
56
  const w = await mountCopy();
57
57
  const button = w.find("button");
58
58
  expect(button.classes()).toContain("input-button");
59
- expect(button.classes()).toContain("inline");
59
+ expect(button.attributes("data-variant")).toBe("inline");
60
60
  expect(button.classes()).toContain("input-copy__button");
61
61
  expect(button.attributes("type")).toBe("button");
62
62
  });
@@ -182,7 +182,7 @@ describe("InputCopy", () => {
182
182
  await nextTick();
183
183
  expect(w.classes()).not.toContain("input-copy-copied");
184
184
  expect(w.find("button").text()).toContain("Copy");
185
- expect(w.find("button").attributes("aria-disabled")).toBe("false");
185
+ expect(w.find("button").attributes("aria-disabled")).toBeUndefined();
186
186
  });
187
187
 
188
188
  it("logs and stays in the resting state when the clipboard write fails", async () => {
@@ -163,11 +163,11 @@ describe("GoogleReviews", () => {
163
163
  setScrollMetrics(list.element, { scrollWidth: 1000, clientWidth: 400, scrollLeft: 0 });
164
164
  await list.trigger("scroll");
165
165
  expect(wrapper.find(".google-reviews-prev").attributes("aria-disabled")).toBe("true");
166
- expect(wrapper.find(".google-reviews-next").attributes("aria-disabled")).toBe("false");
166
+ expect(wrapper.find(".google-reviews-next").attributes("aria-disabled")).toBeUndefined();
167
167
 
168
168
  setScrollMetrics(list.element, { scrollWidth: 1000, clientWidth: 400, scrollLeft: 600 });
169
169
  await list.trigger("scroll");
170
- expect(wrapper.find(".google-reviews-prev").attributes("aria-disabled")).toBe("false");
170
+ expect(wrapper.find(".google-reviews-prev").attributes("aria-disabled")).toBeUndefined();
171
171
  expect(wrapper.find(".google-reviews-next").attributes("aria-disabled")).toBe("true");
172
172
  });
173
173
 
@@ -192,8 +192,8 @@ describe("GoogleReviews", () => {
192
192
  const list = wrapper.find(".google-reviews-list");
193
193
  setScrollMetrics(list.element, { scrollWidth: 1000, clientWidth: 400, scrollLeft: 0 });
194
194
  await list.trigger("scroll");
195
- expect(wrapper.find(".google-reviews-prev .button-text").text()).toBe("Previous reviews");
196
- expect(wrapper.find(".google-reviews-next .button-text").text()).toBe("Next reviews");
195
+ expect(wrapper.find(".google-reviews-prev .input-button-text").text()).toBe("Previous reviews");
196
+ expect(wrapper.find(".google-reviews-next .input-button-text").text()).toBe("Next reviews");
197
197
  });
198
198
 
199
199
  // ─── Copy and slots ────────────────────────────────────────────────────
@@ -155,8 +155,8 @@ cards regardless of each card's description length, without needing CSS subgrid.
155
155
 
156
156
  `.services-card`, `.services-card-image-wrapper`, `.services-card-details`, and `.services-card-footer` all set
157
157
  `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot
158
- button label, since `InputButton`'s `.button-text` is `white-space: nowrap` with no
159
- ellipsis — forces its content's min-content width up through the flex/grid chain and widens
158
+ button label kept on one line with `--input-button-text-white-space: nowrap` (the default was
159
+ `nowrap` until 2026-10-17) — forces its content's min-content width up through the flex/grid chain and widens
160
160
  that one card's grid column in `ServicesCardGrid` wider than its siblings. If a card/column
161
161
  ever looks wider than the rest with content cut off at the edge, check for a long unbreakable
162
162
  string in a slot before assuming it's an image sizing issue — the image is just riding along
@@ -201,7 +201,7 @@ describe("ServicesCardGrid button icon", () => {
201
201
  const wrapper = await mountSuspended(ServicesCardGrid, {
202
202
  props: { servicesData: [makeService("untitled", "")] },
203
203
  });
204
- expect(wrapper.find(".button-text").text()).toBe("Enquire about");
205
- expect(wrapper.find(".button-text").element.textContent).toBe("Enquire about");
204
+ expect(wrapper.find(".input-button-text").text()).toBe("Enquire about");
205
+ expect(wrapper.find(".input-button-text").element.textContent).toBe("Enquire about");
206
206
  });
207
207
  });
@@ -17,9 +17,9 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
17
17
  <div class="services-card-meta">
18
18
  <div class="services-card-meta-duration">60 mins</div>
19
19
  <div class="services-card-meta-price">£50</div>
20
- </div><a href="/services/locs-installation" data-testid="input-button" data-theme="default" class="input-button secondary mbs-24 is-link">
20
+ </div><a href="/services/locs-installation" data-testid="input-button" data-theme="default" data-variant="secondary" data-link="" class="input-button mbs-24">
21
21
  <!--v-if-->
22
- <!--v-if--><span class="button-text">Enquire about Locs Installation</span><span class="btn-icon right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
22
+ <!--v-if--><span class="input-button-text">Enquire about Locs Installation</span><span class="input-button-icon" data-slot="right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
23
23
  <!--v-if-->
24
24
  </a>
25
25
  </div>
@@ -40,9 +40,9 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
40
40
  <div class="services-card-meta">
41
41
  <div class="services-card-meta-duration">60 mins</div>
42
42
  <div class="services-card-meta-price">£50</div>
43
- </div><a href="/services/locs-retwist" data-testid="input-button" data-theme="default" class="input-button secondary mbs-24 is-link">
43
+ </div><a href="/services/locs-retwist" data-testid="input-button" data-theme="default" data-variant="secondary" data-link="" class="input-button mbs-24">
44
44
  <!--v-if-->
45
- <!--v-if--><span class="button-text">Enquire about Locs Retwist</span><span class="btn-icon right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
45
+ <!--v-if--><span class="input-button-text">Enquire about Locs Retwist</span><span class="input-button-icon" data-slot="right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
46
46
  <!--v-if-->
47
47
  </a>
48
48
  </div>
@@ -63,9 +63,9 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
63
63
  <div class="services-card-meta">
64
64
  <div class="services-card-meta-duration">60 mins</div>
65
65
  <div class="services-card-meta-price">£50</div>
66
- </div><a href="/services/colour-treatment" data-testid="input-button" data-theme="default" class="input-button secondary mbs-24 is-link">
66
+ </div><a href="/services/colour-treatment" data-testid="input-button" data-theme="default" data-variant="secondary" data-link="" class="input-button mbs-24">
67
67
  <!--v-if-->
68
- <!--v-if--><span class="button-text">Enquire about Colour Treatment</span><span class="btn-icon right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
68
+ <!--v-if--><span class="input-button-text">Enquire about Colour Treatment</span><span class="input-button-icon" data-slot="right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
69
69
  <!--v-if-->
70
70
  </a>
71
71
  </div>
@@ -110,3 +110,11 @@ tokens instead of the `--theme-error-*` ones** above).
110
110
  `.input-error-message` element. Field wrappers don't forward it, so in practice use the tokens
111
111
  above.
112
112
 
113
+ ---
114
+
115
+ ## Notes
116
+
117
+ - Message text wraps anywhere, so a long URL or unbroken word stays inside the strip. There is no
118
+ line-clamp token: error text should stay readable in full.
119
+ - Blank entries in an array `error-message` are dropped rather than rendered as empty list items.
120
+