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,7 +1,9 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
- import { computed } from "vue";
2
+ import { computed, ref } from "vue";
3
3
  import StorybookComponent from "../InputLabel.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
4
5
  import type { InputUiVariant, InputLabelIndicator } from "~/types/forms/types.forms";
6
+ import type { MediaCanvas } from "~/types/components";
5
7
 
6
8
  interface InputLabelStoryArgs {
7
9
  id: string;
@@ -109,8 +111,43 @@ export default {
109
111
  textLabel: "Email address",
110
112
  htmlLabel: "",
111
113
  },
114
+ decorators: [
115
+ (story, context) => ({
116
+ components: { story, CanvasSwitcher },
117
+ setup() {
118
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
119
+ return { canvasName };
120
+ },
121
+ template: `
122
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
123
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
124
+ </div>
125
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
126
+ <story />
127
+ </div>
128
+ `,
129
+ }),
130
+ ],
131
+ parameters: {
132
+ layout: "fullscreen",
133
+ docs: {
134
+ description: {
135
+ component:
136
+ "The `<label>` every labelled field wrapper renders. Label copy goes in the `textLabel` and `htmlLabel` slots (html first), `id` becomes its `for`, and `indicator` with `required` adds a required or optional marker (falling back to `app.config` `srcdev.inputLabel`). `field-has-error` and `input-variant` are styling hooks only. The control in these stories is a plain input; see [InputTextField](?path=/story/components-forms-input-text-inputtextfield--required) for it inside a real field.",
137
+ },
138
+ },
139
+ },
112
140
  } as Meta<InputLabelStoryArgs>;
113
141
 
142
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
143
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
144
+ const fieldNote = `
145
+ <p style="${storyNoteStyle}">
146
+ Every labelled field wrapper renders this label and forwards its own <code>required</code> prop. See it in a real field in the
147
+ <a href="/?path=/story/components-forms-input-text-inputtextfield--required" target="_top">InputTextField</a> story.
148
+ </p>
149
+ `;
150
+
114
151
  const Template: StoryFn<InputLabelStoryArgs> = (args) => ({
115
152
  components: { StorybookComponent },
116
153
  setup() {
@@ -133,7 +170,8 @@ const Template: StoryFn<InputLabelStoryArgs> = (args) => ({
133
170
  return { args, componentArgs, tokenStyles };
134
171
  },
135
172
  template: `
136
- <div style="max-width: 48rem; margin: 3.6rem; font-size: 1.6rem;" :style="tokenStyles">
173
+ <div style="max-width: 48rem; font-size: 1.6rem;" :style="tokenStyles">
174
+ ${fieldNote}
137
175
  <StorybookComponent v-bind="componentArgs">
138
176
  <template v-if="args.htmlLabel" #htmlLabel><span v-html="args.htmlLabel"></span></template>
139
177
  <template v-if="args.textLabel" #textLabel>{{ args.textLabel }}</template>
@@ -150,17 +188,26 @@ const Template: StoryFn<InputLabelStoryArgs> = (args) => ({
150
188
  });
151
189
 
152
190
  export const TextLabel = Template.bind({});
191
+ TextLabel.parameters = {
192
+ docs: { description: { story: "Plain label text through the `textLabel` slot." } },
193
+ };
153
194
 
154
195
  export const HtmlLabel = Template.bind({});
155
196
  HtmlLabel.args = {
156
197
  textLabel: "",
157
198
  htmlLabel: 'Email address <small style="font-weight: normal;">(work or personal)</small>',
158
199
  };
200
+ HtmlLabel.parameters = {
201
+ docs: { description: { story: "Rich markup through the `htmlLabel` slot." } },
202
+ };
159
203
 
160
204
  export const TextAndHtml = Template.bind({});
161
205
  TextAndHtml.args = {
162
206
  htmlLabel: "<strong>Work</strong> ",
163
207
  };
208
+ TextAndHtml.parameters = {
209
+ docs: { description: { story: "Both slots filled: `htmlLabel` renders first, then `textLabel`." } },
210
+ };
164
211
 
165
212
  export const MarkRequired = Template.bind({});
166
213
  MarkRequired.args = { indicator: "required", required: true };
@@ -234,3 +281,22 @@ CustomTokens.decorators = [
234
281
  `,
235
282
  }),
236
283
  ];
284
+
285
+ export const StressTest = Template.bind({});
286
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
287
+ StressTest.args = {
288
+ textLabel:
289
+ "📧 Elektronischepostadressefürdiebenachrichtigungüberbestellungen https://example.com/a/very/long/unbroken/url/for/the/label <b>not bold</b> البريد الإلكتروني",
290
+ indicator: "optional",
291
+ required: false,
292
+ optionalText: "(freiwilligeAngabekeinePflichtfeldbitteleerlassenwennnichtzutreffend)",
293
+ };
294
+ StressTest.parameters = {
295
+ initialCanvas: "mobileCanvas",
296
+ docs: {
297
+ description: {
298
+ story:
299
+ "Long unbroken German, a long URL, emoji, HTML-like and right-to-left text in the label, plus an unbroken optional marker. Check at every canvas width: the label and the marker wrap inside the dashed outline, markup renders as text, and the marker stays after the text in RTL. Switch `indicator` to required and turn `required` on to check the required marker. Label copy arrives through slots, so there is no line-clamp token: clamp your own slot markup if you need to.",
300
+ },
301
+ },
302
+ };
@@ -1,14 +1,24 @@
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 InputLabel from "../InputLabel.vue";
4
4
 
5
- const createWrapper = (props = {}, slots = {}) =>
6
- mountSuspended(InputLabel, {
5
+ const mounted: { unmount: () => void }[] = [];
6
+
7
+ const createWrapper = async (props = {}, slots = {}) => {
8
+ const wrapper = await mountSuspended(InputLabel, {
7
9
  props: { id: "test-input", ...props },
8
10
  slots,
9
11
  });
12
+ mounted.push(wrapper);
13
+ return wrapper;
14
+ };
10
15
 
11
16
  describe("InputLabel", () => {
17
+ afterEach(() => {
18
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
19
+ vi.restoreAllMocks();
20
+ });
21
+
12
22
  describe("rendering", () => {
13
23
  it("renders a label linked to the control id", async () => {
14
24
  const wrapper = await createWrapper({ id: "email-input" });
@@ -18,7 +18,8 @@ token, same as `InputText`.
18
18
  | `--input-number-surface` | `var(--theme-input-surface)` | Wrapper, input, and embedded-button background |
19
19
  | `--input-number-surface-hover` | `var(--theme-input-surface-hover)` | Embedded left/right button background on hover |
20
20
  | `--input-number-border` | `var(--theme-border)` | Border/divider colour (`normal`/`underlined` variant border, and the divider between the input and an embedded button) |
21
- | `--input-number-border-hover` | `var(--theme-border-focus)` | Wrapper outline colour on mouse hover over the input or either step button (`normal` variant only) |
21
+ | `--input-number-border-hover` | the resolved border | 1px wrapper outline colour on mouse hover over the input or either step button (`normal` variant only) |
22
+ | `--input-number-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (`normal` only). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
22
23
  | `--input-number-border-focus` | `var(--theme-border-focus)` | Wrapper outline colour on `:focus-visible` (`normal` variant), and the embedded button's own `:focus-visible` outline |
23
24
  | `--input-number-text-color` | `var(--theme-input-text-color-normal)` | Input text colour |
24
25
  | `--input-number-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
@@ -33,6 +34,11 @@ and `step` and capped at `12em`).
33
34
 
34
35
  ---
35
36
 
37
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
38
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
39
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
40
+ > unchanged and sits just outside the ring.
41
+
36
42
  ## State hooks
37
43
 
38
44
  | Attribute | On | When |
@@ -123,11 +123,10 @@ const inputmode = computed(() => {
123
123
  border: var(--form-element-border-width) solid var(--_border);
124
124
  border-radius: var(--form-input-border-radius);
125
125
  outline: var(--form-element-outline-width) solid transparent;
126
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-number-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
126
127
 
127
128
  &:has(input:hover, button:hover) {
128
- outline: var(--form-element-outline-width-focus) solid
129
- var(--input-number-border-hover, var(--theme-border-focus));
130
- outline-offset: var(--form-element-outline-offset-focus);
129
+ outline-color: var(--input-number-border-hover, var(--_border));
131
130
  }
132
131
 
133
132
  &:has(input:focus-visible, button:focus-visible) {
@@ -167,8 +166,8 @@ const inputmode = computed(() => {
167
166
  outline-offset: -4px;
168
167
  }
169
168
 
170
- &.icon-only {
171
- .btn-icon {
169
+ &[data-icon-only] {
170
+ & .input-button-icon {
172
171
  margin: 0;
173
172
  }
174
173
  }
@@ -18,7 +18,8 @@ for the two-tier shape.
18
18
  | `--input-otp-surface` | `var(--theme-input-surface)` | Box background |
19
19
  | `--input-otp-text-color` | `var(--theme-input-text-color-normal)` | Digit colour |
20
20
  | `--input-otp-border` | `var(--theme-border)` | Box border (`normal`) or bottom border (`underlined`) |
21
- | `--input-otp-border-hover` | `var(--input-otp-border-focus)` | Outline colour on hover (`normal` variant) |
21
+ | `--input-otp-border-hover` | the resolved border | 1px outline colour on hover (`normal` variant) |
22
+ | `--input-otp-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (each box, `normal` only). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
22
23
  | `--input-otp-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible`, and the bottom border colour on focus (`underlined`) |
23
24
 
24
25
  ### Field (`InputOtpField`)
@@ -35,6 +36,11 @@ Border/outline widths and transition duration are the shared form geometry token
35
36
  Private tokens (not public API): `--_border` and `--_border-focus` on `.input-otp`, which only
36
37
  resolve the two public border tokens once for reuse across the variant rules.
37
38
 
39
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
40
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
41
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
42
+ > unchanged and sits just outside the ring.
43
+
38
44
  ## State hooks
39
45
 
40
46
  | Hook | Where | Meaning |
@@ -230,10 +230,10 @@ function onFocus(event: FocusEvent) {
230
230
  border: var(--form-element-border-width) solid var(--_border);
231
231
  border-radius: var(--input-otp-border-radius, var(--form-input-border-radius));
232
232
  outline: var(--form-element-outline-width) solid transparent;
233
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-otp-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
233
234
 
234
235
  &:hover {
235
- outline: var(--form-element-outline-width-focus) solid var(--input-otp-border-hover, var(--_border-focus));
236
- outline-offset: var(--form-element-outline-offset-focus);
236
+ outline-color: var(--input-otp-border-hover, var(--_border));
237
237
  }
238
238
 
239
239
  &:focus-visible {
@@ -103,7 +103,7 @@ watch(
103
103
  width: var(--input-range-button-size);
104
104
  height: var(--input-range-button-size);
105
105
 
106
- .btn-icon {
106
+ & .input-button-icon {
107
107
  margin: initial;
108
108
  }
109
109
  }
@@ -17,7 +17,8 @@ to override just this component; leave it unset and it inherits the shared `--th
17
17
  | `--input-select-surface` | `var(--theme-input-surface)` | Wrapper/select background |
18
18
  | `--input-select-text-color` | `var(--theme-input-text-color-normal)` | Select text colour (added 2026-10-16; it inherited the page colour before) |
19
19
  | `--input-select-border` | `var(--theme-border)` | Border colour (`normal`/`underlined` variants and the open picker, resting) |
20
- | `--input-select-border-hover` | `var(--theme-border-focus)` | Outline colour on hover (mouse) |
20
+ | `--input-select-border-hover` | the resolved border (`normal`), `var(--theme-border-focus)` (`underlined`) | Outline colour on hover (mouse): 1px with no offset on `normal`, 2px at a 2px offset on `underlined` |
21
+ | `--input-select-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (`normal` only). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
21
22
  | `--input-select-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (keyboard/assistive), and the open picker's outline |
22
23
 
23
24
  ### Options
@@ -41,6 +42,11 @@ shows and ignores them.
41
42
 
42
43
  ---
43
44
 
45
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
46
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
47
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
48
+ > unchanged and sits just outside the ring.
49
+
44
50
  ## State hooks
45
51
 
46
52
  | Attribute | On | When |
@@ -112,16 +112,25 @@ watch(
112
112
  border: var(--form-element-border-width) solid var(--_border);
113
113
  border-radius: var(--form-input-border-radius);
114
114
  outline: var(--form-element-outline-width) solid transparent;
115
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-select-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
116
+
117
+ &:has(:hover) {
118
+ outline-color: var(--input-select-border-hover, var(--_border));
119
+ }
120
+
121
+ &:has(select:focus-visible) {
122
+ outline-color: var(--_border-focus);
123
+ }
115
124
  }
116
125
 
117
126
  &[data-input-variant="underlined"] {
118
127
  border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
119
- }
120
128
 
121
- &:has(:hover) {
122
- outline: var(--form-element-outline-width-focus) solid
123
- var(--input-select-border-hover, var(--theme-border-focus));
124
- outline-offset: var(--form-element-outline-offset-focus);
129
+ &:has(:hover) {
130
+ outline: var(--form-element-outline-width-focus) solid
131
+ var(--input-select-border-hover, var(--theme-border-focus));
132
+ outline-offset: var(--form-element-outline-offset-focus);
133
+ }
125
134
  }
126
135
 
127
136
  &:has(select:focus-visible) {
@@ -17,7 +17,8 @@ override just this component; leave it unset and it inherits the shared `--theme
17
17
  | `--input-text-surface` | `var(--theme-input-surface)` | Wrapper background, and slot `InputButton` background |
18
18
  | `--input-text-surface-hover` | `var(--theme-input-surface-hover)` | Slot `InputButton` background on hover |
19
19
  | `--input-text-border` | `var(--theme-border)` | Border colour (`normal`/`underlined`, resting), and the stepper's button dividers |
20
- | `--input-text-border-hover` | `var(--theme-border-focus)` | Outline colour on hover (`normal` only) |
20
+ | `--input-text-border-hover` | the resolved border | 1px outline colour on hover (`normal` only) |
21
+ | `--input-text-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (`normal` only). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
21
22
  | `--input-text-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (`normal` only), and a slot button's own focus outline |
22
23
  | `--input-text-color` | `var(--theme-input-text-color-normal)` | Input text and slot button colour, and autofilled text (global rule) |
23
24
  | `--input-text-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
@@ -31,6 +32,11 @@ read in several places.
31
32
 
32
33
  ---
33
34
 
35
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
36
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
37
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
38
+ > unchanged and sits just outside the ring.
39
+
34
40
  ## State hooks
35
41
 
36
42
  | Attribute | On | When |
@@ -131,12 +131,12 @@ watch(
131
131
  border: var(--form-element-border-width) solid var(--_border);
132
132
  border-radius: var(--form-input-border-radius);
133
133
  outline: var(--form-element-outline-width) solid transparent;
134
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-text-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
134
135
 
135
136
  padding-inline: var(--input-padding-inline);
136
137
 
137
138
  &:has(input:is(:hover), button:is(:hover)) {
138
- outline: var(--form-element-outline-width-focus) solid var(--input-text-border-hover, var(--theme-border-focus));
139
- outline-offset: var(--form-element-outline-offset-focus);
139
+ outline-color: var(--input-text-border-hover, var(--_border));
140
140
  }
141
141
 
142
142
  &:has(input:focus-visible, button:focus-visible) {
@@ -180,8 +180,8 @@ watch(
180
180
  outline-offset: -4px;
181
181
  }
182
182
 
183
- &.icon-only {
184
- .btn-icon {
183
+ &[data-icon-only] {
184
+ & .input-button-icon {
185
185
  margin: 0;
186
186
  }
187
187
  }
@@ -18,7 +18,8 @@ This shape mirrors `InputText`'s token set (see its own CONSUMER-STYLING.md).
18
18
  |---|---|---|
19
19
  | `--input-textarea-surface` | `var(--theme-input-surface)` | Wrapper background |
20
20
  | `--input-textarea-border` | `var(--theme-border)` | Border colour (`normal`/`underlined` variant border) |
21
- | `--input-textarea-border-hover` | `var(--theme-border-focus)` | Wrapper outline colour on mouse hover (`normal` only) |
21
+ | `--input-textarea-border-hover` | the resolved border | 1px wrapper outline colour on mouse hover (`normal` only) |
22
+ | `--input-textarea-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (`normal` only). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
22
23
  | `--input-textarea-border-focus` | `var(--theme-border-focus)` | Wrapper outline colour on `:focus-visible` (`normal` only) |
23
24
  | `--input-textarea-text-color` | `var(--theme-input-text-color-normal)` | Textarea text colour |
24
25
  | `--input-textarea-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
@@ -41,6 +42,11 @@ Private (not public API): `--_border`, resolved once and read by both border var
41
42
 
42
43
  ---
43
44
 
45
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
46
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
47
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
48
+ > unchanged and sits just outside the ring.
49
+
44
50
  ## State hooks
45
51
 
46
52
  | Attribute | On | When |
@@ -105,11 +105,10 @@ watch(
105
105
  border: var(--form-element-border-width) solid var(--_border);
106
106
  border-radius: var(--form-input-border-radius);
107
107
  outline: var(--form-element-outline-width) solid transparent;
108
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--input-textarea-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
108
109
 
109
110
  &:has(textarea:is(:hover)) {
110
- outline: var(--form-element-outline-width-focus) solid
111
- var(--input-textarea-border-hover, var(--theme-border-focus));
112
- outline-offset: var(--form-element-outline-offset-focus);
111
+ outline-color: var(--input-textarea-border-hover, var(--_border));
113
112
  }
114
113
 
115
114
  &:has(textarea:focus-visible) {
@@ -12,7 +12,7 @@ import ToggleSwitchField from "../../toggle-switch/ToggleSwitchField.vue";
12
12
  import MultipleCheckboxes from "../../input-checkbox/MultipleCheckboxes.vue";
13
13
  import SingleCheckbox from "../../input-checkbox/SingleCheckbox.vue";
14
14
  import MultipleRadiobuttons from "../../input-radio/MultipleRadiobuttons.vue";
15
- import TripleToggleSwitch from "../../triple-toggle-switch/TripleToggleSwitch.vue";
15
+ import SegmentedControl from "../../segmented-control/SegmentedControl.vue";
16
16
  import InputButton from "../../input-button/InputButton.vue";
17
17
  import FormField from "../../form-field/FormField.vue";
18
18
  import FormWrapper from "../../form-wrapper/FormWrapper.vue";
@@ -31,7 +31,7 @@ interface MigratedFieldsFormStoryArgs {
31
31
  // InputTextAsNumberField, InputRangeField (InputRange), InputNumberField
32
32
  // (InputNumber), InputOtpField (InputOtp), InputTextareaField (InputTextarea), InputSelectField
33
33
  // (InputSelect), ToggleSwitchField, stacked and inline (ToggleSwitch),
34
- // MultipleCheckboxes, SingleCheckbox, MultipleRadiobuttons, TripleToggleSwitch ("Theme
34
+ // MultipleCheckboxes, SingleCheckbox, MultipleRadiobuttons, SegmentedControl ("Theme
35
35
  // preference": no Field wrapper or error state, so it sits under a plain caption).
36
36
  // - Rendered inside other fields: FormWrapper (wraps the form), FormField (wraps every field),
37
37
  // FormFieldset (checkbox/radio groups), InputLabel, InputDescription (the "Full name" and
@@ -71,7 +71,7 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
71
71
  MultipleCheckboxes,
72
72
  SingleCheckbox,
73
73
  MultipleRadiobuttons,
74
- TripleToggleSwitch,
74
+ SegmentedControl,
75
75
  InputButton,
76
76
  FormField,
77
77
  FormWrapper,
@@ -418,7 +418,7 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
418
418
  <FormField width="wide" :has-gutter="false">
419
419
  <div style="display: grid; gap: 0.8rem; justify-items: start;">
420
420
  <span>Theme preference</span>
421
- <TripleToggleSwitch
421
+ <SegmentedControl
422
422
  v-model="state.themePreference"
423
423
  v-model:field-data="themeOptions"
424
424
  name="themePreference"
@@ -8,8 +8,12 @@ directly: `InputButton` renders it when `has-pending-effect` is set, and it anim
8
8
  <InputButton button-text="Save" has-pending-effect :is-pending="saving" />
9
9
  ```
10
10
 
11
- The CSS keys off the host button's classes: `.pending-effect` (added by `has-pending-effect`),
12
- `.is-pending`, and `.icon-only`. The SVG itself is `.pending-effect-container`.
11
+ The CSS keys off the host button's state attributes: `.input-button[data-pending-effect]` (added by
12
+ `has-pending-effect`), `[data-pending]`, and `[data-icon-only]`. The SVG itself is
13
+ `.pending-effect-container`.
14
+
15
+ > **Changed 2026-10-17**: these were the classes `.pending-effect`, `.is-pending` and `.icon-only`
16
+ > until `InputButton` moved its state to `data-*` attributes.
13
17
 
14
18
  The line sits outside the button's edge, so nothing around it may clip overflow. `InputButton`
15
19
  sets `overflow: visible` on itself when `has-pending-effect` is on, but an ancestor with
@@ -50,7 +54,7 @@ positioning maths) and `--_offset` (the resolved `--pending-effect-offset`).
50
54
  ## Reduced motion
51
55
 
52
56
  Under `prefers-reduced-motion: reduce` the effect stays hidden. The host button still gets
53
- `pointer-events: none` while `.is-pending` is set, from this component's CSS, so it can't be
57
+ `pointer-events: none` while `[data-pending]` is set, from this component's CSS, so it can't be
54
58
  clicked twice.
55
59
 
56
60
  ---
@@ -34,7 +34,7 @@ watch(
34
34
 
35
35
  <style lang="css">
36
36
  @layer components {
37
- .pending-effect {
37
+ .input-button[data-pending-effect] {
38
38
  position: relative;
39
39
 
40
40
  .pending-effect-container {
@@ -72,14 +72,14 @@ watch(
72
72
  }
73
73
  }
74
74
 
75
- &.icon-only .pending-effect-container {
75
+ &[data-icon-only] .pending-effect-container {
76
76
  .pending-effect-blur,
77
77
  .pending-effect-line {
78
78
  rx: var(--pending-effect-border-radius-icon-only, 100vw);
79
79
  }
80
80
  }
81
81
 
82
- &.is-pending {
82
+ &[data-pending] {
83
83
  pointer-events: none;
84
84
  cursor: wait;
85
85
 
@@ -54,7 +54,7 @@ describe("PendingEffect", () => {
54
54
  props: { buttonText: "Save", hasPendingEffect: true },
55
55
  });
56
56
  expect(withEffect.find(".pending-effect-container").exists()).toBe(true);
57
- expect(withEffect.find("button").classes()).toContain("pending-effect");
57
+ expect(withEffect.find("button").attributes("data-pending-effect")).toBe("");
58
58
  });
59
59
 
60
60
  it("passes the button theme through", async () => {
@@ -0,0 +1,125 @@
1
+ # SegmentedControl — Consumer Styling Guide
2
+
3
+ > **Renamed 2026-10-17**: `TripleToggleSwitch` → `SegmentedControl`, since it handles any number
4
+ > of options. Every class and token changed prefix: `.triple-toggle-switch*` → `.segmented-control*`
5
+ > and `--triple-toggle-switch-*` → `--segmented-control-*`, with the rest of each name unchanged.
6
+ >
7
+ > **Renamed 2026-09-27**: `TripleToggleSwitchCore` → `TripleToggleSwitch`. The root class was
8
+ > already `.triple-toggle-switch`, and no token names changed.
9
+
10
+ ## Public token API
11
+
12
+ Colour tokens fall back to the shared `--theme-*` tokens every other themed component uses, so
13
+ leave them unset to follow the site theme. See `theming-component-token-pattern.md`.
14
+
15
+ ### Wrapper
16
+
17
+ | Token | Default | Controls |
18
+ |---|---|---|
19
+ | `--segmented-control-surface` | `var(--theme-input-surface)` | Wrapper background |
20
+ | `--segmented-control-border` | `var(--theme-border)` | Wrapper border colour |
21
+ | `--segmented-control-border-hover` | the resolved border | 1px wrapper outline colour on hover |
22
+ | `--segmented-control-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (on the wrapper). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
23
+ | `--segmented-control-ring-focus` | `var(--theme-ring)` | Wrapper outline colour while a radio has `:focus-visible` |
24
+ | `--segmented-control-padding` | `0.6rem` | Wrapper padding |
25
+ | `--segmented-control-gap` | `1rem` | Gap between the options (also drives the marker's position) |
26
+
27
+ ### Selected-option marker
28
+
29
+ | Token | Default | Controls |
30
+ |---|---|---|
31
+ | `--segmented-control-marker-surface` | `var(--theme-surface)` | Marker background when the selected value isn't `system`/`light`/`dark` |
32
+ | `--segmented-control-marker-border` | `var(--theme-text)` | Marker border colour |
33
+ | `--segmented-control-marker-gradient-system` | green `radial-gradient(...)` | Marker `background-image` when `system` is selected |
34
+ | `--segmented-control-marker-gradient-light` | red/orange `radial-gradient(...)` | Marker `background-image` when `light` is selected |
35
+ | `--segmented-control-marker-gradient-dark` | black/grey `radial-gradient(...)` | Marker `background-image` when `dark` is selected |
36
+
37
+ > **Changed 2026-09-27**: the three marker gradients were hardcoded literals with no override hook.
38
+ > They're now public tokens; the defaults are unchanged. Any `<image>` value works
39
+ > (`linear-gradient(...)`, `url(...)`, `none`).
40
+
41
+ ### Options
42
+
43
+ | Token | Default | Controls |
44
+ |---|---|---|
45
+ | `--segmented-control-option-padding` | `0.5rem` | Padding inside each option circle |
46
+ | `--segmented-control-icon-size` | `2rem` | Icon `font-size` (and the hidden radio's hit area) |
47
+ | `--segmented-control-option-border` | `#00000025` | Each option's resting border colour |
48
+ | `--segmented-control-option-border-hover` | `var(--theme-text)` | Option outline colour on hover |
49
+ | `--segmented-control-option-ring-focus` | `var(--theme-ring)` | Option outline colour on `:focus-visible` |
50
+ | `--segmented-control-option-icon-color` | `var(--theme-text)` | Icon colour, resting |
51
+ | `--segmented-control-option-icon-color-active` | `var(--theme-on-surface)` | Icon colour, selected |
52
+
53
+ > **Changed 2026-10-17**: the marker surface defaulted to `--theme-input-surface` (white) while
54
+ > the selected icon defaulted to white too, so any value other than `system`/`light`/`dark` showed
55
+ > a white icon on a white marker. The marker now defaults to `--theme-surface` and the icons to
56
+ > `--theme-text`/`--theme-on-surface`, replacing the raw `--slate-10`/`--slate-00` palette tokens
57
+ > (close to the same colours on the default theme).
58
+
59
+ Also read (shared, not component-specific): `--form-element-border-width`,
60
+ `--form-element-outline-width`, `--theme-form-transition-duration`. See
61
+ `theming-form-geometry-tokens.md`.
62
+
63
+ Private tokens (not public API): `--_form-items-gap`, `--_scheme-icon-font-size` (each read in
64
+ several places, fed by the public gap/icon-size tokens) and `--_select-scheme-group-background-color`/
65
+ `--_select-scheme-group-background-image` (swapped per selected value).
66
+
67
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
68
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
69
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
70
+ > unchanged and sits just outside the ring.
71
+
72
+ ## State hooks
73
+
74
+ - **Selected value**: the marker gradients key off `:has(input[value="system" | "light" | "dark"]:checked)`
75
+ on the root. These three values are hardcoded, a legacy of the one real consumer
76
+ (`DisplayThemeSwitch`). Any other value shows `--segmented-control-marker-surface` instead.
77
+ - **Each option** (`.segmented-control-option`, the `<label>`) carries `data-value` (the
78
+ option's value) and `data-checked` when selected. Target one with
79
+ `.segmented-control-option[data-value="light"]`.
80
+ - **Root** has `data-has-selection` when the value matches an option; without it the marker is
81
+ hidden.
82
+ - **Theme**: `data-theme` on the root, from the `theme` prop.
83
+ - **Inner classes**: `.segmented-control-wrapper`, `.segmented-control-marker-layer`,
84
+ `.segmented-control-marker`, `.segmented-control-options` (the `role="radiogroup"`),
85
+ `.segmented-control-option`, `.segmented-control-option-icon`,
86
+ `.segmented-control-option-input`.
87
+
88
+ > **Changed 2026-10-17**: the inner classes were generic (`.selected-option-marker-wrapper`,
89
+ > `.selected-option-marker`, `.option-group-wrapper`, `.option-group`, `.option-icon`,
90
+ > `.option-input`) and collided with consumer CSS. State moved too: `.option-icon.active` →
91
+ > `.segmented-control-option[data-checked]`, `.option-icon.light` (the option id as a class)
92
+ > → `.segmented-control-option[data-value="light"]`, and `.selected-option-marker.show` is gone:
93
+ > the marker is positioned in CSS from the first paint, no measuring.
94
+
95
+ ## Motion
96
+
97
+ The marker slides between options over the `stepAnimationDuration` prop (default `250ms`). Under
98
+ `prefers-reduced-motion: reduce` the slide transition is removed and the marker jumps.
99
+
100
+ ## Global theming
101
+
102
+ ```css
103
+ :where(html) {
104
+ --segmented-control-surface: var(--rose-09);
105
+ --segmented-control-option-ring-focus: var(--rose-04);
106
+ }
107
+ ```
108
+
109
+ ## Local overrides
110
+
111
+ Set the tokens above on an element you own (a page or section class, or a class added with
112
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
113
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
114
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the
115
+ component need `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
116
+
117
+ **Caveat:** when used through `DisplayThemeSwitch`, that wrapper re-declares the four sizing tokens
118
+ (`--segmented-control-gap`, `-padding`, `-option-padding`, `-icon-size`) on the root element, so
119
+ setting them on an ancestor has no effect there. Set them on the component itself (via
120
+ `style` or a passthrough class) instead. Used directly, `SegmentedControl` re-declares nothing.
121
+
122
+ ## Class passthrough
123
+
124
+ `style-class-passthrough` adds classes to the root `.segmented-control` element, alongside
125
+ `data-theme`. `DisplayThemeSwitch` uses it for its `colour-scheme-select` and `small` classes.