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
@@ -16,8 +16,8 @@
16
16
  <Icon :name="icon" class="input-error-message-icon" aria-hidden="true" />
17
17
  </div>
18
18
  <div class="input-error-message-text">
19
- <ul v-if="isArray" class="input-error-message-list">
20
- <li v-for="(message, index) in errorMessage" :key="index" class="input-error-message-list-item">
19
+ <ul v-if="isArray" class="input-error-message-list" role="list">
20
+ <li v-for="(message, index) in messages" :key="index" class="input-error-message-list-item">
21
21
  {{ message }}
22
22
  </li>
23
23
  </ul>
@@ -52,6 +52,9 @@ const props = withDefaults(defineProps<Props>(), {
52
52
  });
53
53
 
54
54
  const isArray = computed(() => Array.isArray(props.errorMessage));
55
+ const messages = computed(() =>
56
+ Array.isArray(props.errorMessage) ? props.errorMessage.filter((message) => String(message).trim() !== "") : []
57
+ );
55
58
 
56
59
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
57
60
 
@@ -141,48 +144,51 @@ watch(
141
144
  outline-color: transparent;
142
145
  }
143
146
  }
144
- }
145
147
 
146
- .input-error-message-inner {
147
- align-items: center;
148
- overflow: hidden;
149
- }
148
+ & .input-error-message-inner {
149
+ align-items: center;
150
+ overflow: hidden;
151
+ }
150
152
 
151
- .input-error-message-content {
152
- display: flex;
153
- align-items: center;
154
- }
153
+ & .input-error-message-content {
154
+ display: flex;
155
+ align-items: center;
156
+ }
155
157
 
156
- .input-error-message-icon-wrapper {
157
- display: inline-block;
158
- padding-inline-start: var(--_padding-inline);
159
- }
158
+ & .input-error-message-icon-wrapper {
159
+ display: inline-block;
160
+ flex-shrink: 0;
161
+ padding-inline-start: var(--_padding-inline);
162
+ }
160
163
 
161
- .input-error-message-icon {
162
- color: var(--input-error-icon-color, var(--_text-color));
163
- font-size: var(--input-error-icon-size, 1em);
164
- transform: translateY(3px);
165
- }
164
+ & .input-error-message-icon {
165
+ color: var(--input-error-icon-color, var(--_text-color));
166
+ font-size: var(--input-error-icon-size, 1em);
167
+ transform: translateY(3px);
168
+ }
166
169
 
167
- .input-error-message-text {
168
- display: inline-block;
169
- flex-grow: 1;
170
- font-family: var(--input-error-font-family, var(--font-family));
171
- font-size: var(--input-error-font-size, 1.6rem);
172
- font-weight: var(--input-error-font-weight, 500);
173
- padding-block: var(--input-error-padding-block-start, var(--_padding-block-start))
174
- var(--input-error-padding-block-end, 1rem);
175
- padding-inline: var(--_padding-inline);
176
- }
170
+ & .input-error-message-text {
171
+ display: inline-block;
172
+ flex-grow: 1;
173
+ min-inline-size: 0;
174
+ overflow-wrap: anywhere;
175
+ font-family: var(--input-error-font-family, var(--font-family));
176
+ font-size: var(--input-error-font-size, 1.6rem);
177
+ font-weight: var(--input-error-font-weight, 500);
178
+ padding-block: var(--input-error-padding-block-start, var(--_padding-block-start))
179
+ var(--input-error-padding-block-end, 1rem);
180
+ padding-inline: var(--_padding-inline);
181
+ }
177
182
 
178
- .input-error-message-list {
179
- list-style-type: none;
180
- padding-inline-start: 0;
181
- margin-block: 0;
182
- }
183
+ & .input-error-message-list {
184
+ list-style-type: none;
185
+ padding-inline-start: 0;
186
+ margin-block: 0;
187
+ }
183
188
 
184
- .input-error-message-list-item + .input-error-message-list-item {
185
- margin-block-start: var(--input-error-list-gap, 0.6rem);
189
+ & .input-error-message-list-item + .input-error-message-list-item {
190
+ margin-block-start: var(--input-error-list-gap, 0.6rem);
191
+ }
186
192
  }
187
193
  }
188
194
  </style>
@@ -1,5 +1,8 @@
1
+ import { ref } from "vue";
1
2
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
3
  import StorybookComponent from "../InputError.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { MediaCanvas } from "~/types/components";
3
6
 
4
7
  interface InputErrorStoryArgs {
5
8
  id: string;
@@ -44,15 +47,52 @@ export default {
44
47
  icon: "radix-icons:circle-backslash",
45
48
  styleClassPassthrough: "",
46
49
  },
50
+ decorators: [
51
+ (story, context) => ({
52
+ components: { story, CanvasSwitcher },
53
+ setup() {
54
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
55
+ return { canvasName };
56
+ },
57
+ template: `
58
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
59
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
60
+ </div>
61
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
62
+ <story />
63
+ </div>
64
+ `,
65
+ }),
66
+ ],
67
+ parameters: {
68
+ layout: "fullscreen",
69
+ docs: {
70
+ description: {
71
+ component:
72
+ "The error strip every `*Field` wrapper renders under its control, in grid row 2. `error-message` takes a string or an array (rendered as a list), `show-error` opens it, `is-detached` chooses between tucked under the control and sitting below it with a gap, and `input-variant` matches the control's variant. The control in these stories is a stand-in; see [InputTextField](?path=/story/components-forms-input-text-inputtextfield--with-error) (attached) and [MultipleCheckboxes](?path=/story/components-forms-input-checkbox-multiplecheckboxes--error-state) (detached) for it in real use.",
73
+ },
74
+ },
75
+ },
47
76
  } as Meta<InputErrorStoryArgs>;
48
77
 
78
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
79
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
80
+ const fieldNote = `
81
+ <p style="${storyNoteStyle}">
82
+ Every field wrapper renders this strip for you. See it attached under a real control in the
83
+ <a href="/?path=/story/components-forms-input-text-inputtextfield--with-error" target="_top">InputTextField</a>
84
+ story, and detached under a group in the
85
+ <a href="/?path=/story/components-forms-input-checkbox-multiplecheckboxes--error-state" target="_top">MultipleCheckboxes</a>
86
+ story.
87
+ </p>
88
+ `;
89
+
49
90
  const demoStyles = `
50
91
  <component is="style">
51
92
  .input-error-story-field {
52
93
  display: grid;
53
94
  grid-template-rows: auto auto;
54
95
  max-width: 48rem;
55
- margin: 3.6rem;
56
96
  }
57
97
  .input-error-story-control {
58
98
  grid-row: 1;
@@ -84,6 +124,7 @@ const Template: StoryFn<InputErrorStoryArgs> = (args) => ({
84
124
  template: `
85
125
  <div>
86
126
  ${demoStyles}
127
+ ${fieldNote}
87
128
  <div
88
129
  class="input-error-story-field"
89
130
  :data-invalid="args.showError ? '' : undefined"
@@ -105,21 +146,58 @@ const Template: StoryFn<InputErrorStoryArgs> = (args) => ({
105
146
  });
106
147
 
107
148
  export const Attached = Template.bind({});
149
+ Attached.parameters = {
150
+ docs: { description: { story: "The default: tucked under the control's bottom edge, sharing its border." } },
151
+ };
108
152
 
109
153
  export const Detached = Template.bind({});
110
154
  Detached.args = { isDetached: true };
155
+ Detached.parameters = {
156
+ docs: { description: { story: "Sits below the control with a gap (`--input-error-detached-offset`) and fully rounded corners, as used under checkbox and radio groups." } },
157
+ };
111
158
 
112
159
  export const MultipleMessages = Template.bind({});
113
160
  MultipleMessages.args = {
114
161
  isDetached: true,
115
162
  errorMessage: ["Password must be at least 12 characters", "Password must contain a number"],
116
163
  };
164
+ MultipleMessages.parameters = {
165
+ docs: { description: { story: "An array `error-message` renders as a list, spaced by `--input-error-list-gap`." } },
166
+ };
117
167
 
118
168
  export const Underlined = Template.bind({});
119
169
  Underlined.args = { inputVariant: "underlined" };
170
+ Underlined.parameters = {
171
+ docs: { description: { story: "Square corners, and no outline while attached, to match an underlined control." } },
172
+ };
120
173
 
121
174
  export const Hidden = Template.bind({});
122
175
  Hidden.args = { showError: false };
123
176
  Hidden.parameters = {
124
177
  docs: { description: { story: "Toggle `showError` to see the open/close animation." } },
125
178
  };
179
+
180
+ export const StressTest = Template.bind({});
181
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
182
+ StressTest.args = {
183
+ isDetached: true,
184
+ errorMessage: [
185
+ "DieseEingabeistleiderungültigbitteüberprüfenSiedieSchreibweiseundversuchenSieeserneut",
186
+ "https://example.com/a/very/long/unbroken/url/that/should/wrap/inside/the/strip?with=query&and=more",
187
+ "<b>not bold</b> <script>alert(1)</script>",
188
+ "⚠️ Bitte geben Sie eine gültige E-Mail-Adresse ein. Wir verwenden Ihre Adresse ausschließlich für Rückfragen zu Ihrer Bestellung bei Alex Morgan in Northtown.",
189
+ "يرجى إدخال عنوان بريد إلكتروني صالح",
190
+ "x",
191
+ "",
192
+ ],
193
+ };
194
+ StressTest.parameters = {
195
+ initialCanvas: "mobileCanvas",
196
+ docs: {
197
+ story: { inline: false, height: "720px" },
198
+ description: {
199
+ story:
200
+ "Long unbroken German, a long URL, HTML-like text, emoji, right-to-left text, a single character and an empty string in one array. Check at every canvas width: every message wraps inside the strip (no horizontal overflow past the dashed outline), the icon keeps its size, markup renders as text, and the empty item takes no visible space. Switch `errorMessage` to a single long string to check the non-list path too. Error text is never line-clamped: a truncated validation message can't be acted on.",
201
+ },
202
+ },
203
+ };
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import type { VueWrapper } from "@vue/test-utils";
3
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
4
4
  import { nextTick } from "vue";
@@ -22,6 +22,11 @@ const wrapperFactory = (propsData = {}) => {
22
22
  };
23
23
 
24
24
  describe("InputError", () => {
25
+ afterEach(() => {
26
+ wrapper?.unmount();
27
+ vi.restoreAllMocks();
28
+ });
29
+
25
30
  it("renders the root with id, testid, error theme and passthrough classes", async () => {
26
31
  wrapper = await wrapperFactory();
27
32
  const root = wrapper.find(".input-error-message");
@@ -92,6 +97,29 @@ describe("InputError", () => {
92
97
  expect(items[1]!.text()).toBe("Hello World 2");
93
98
  });
94
99
 
100
+ it("keeps list semantics on the unstyled list", async () => {
101
+ wrapper = await wrapperFactory({ errorMessage: ["Hello World", "Hello World 2"] });
102
+
103
+ expect(wrapper.find(".input-error-message-list").attributes("role")).toBe("list");
104
+ });
105
+
106
+ it("drops blank entries from an array of messages", async () => {
107
+ wrapper = await wrapperFactory({ errorMessage: ["First", "", " ", "Second"] });
108
+ const items = wrapper.findAll(".input-error-message-list-item");
109
+
110
+ expect(items).toHaveLength(2);
111
+ expect(items[0]!.text()).toBe("First");
112
+ expect(items[1]!.text()).toBe("Second");
113
+ });
114
+
115
+ it("renders HTML-like message text as text", async () => {
116
+ wrapper = await wrapperFactory({ errorMessage: "<b>not bold</b>" });
117
+ const message = wrapper.find(".input-error-message-single");
118
+
119
+ expect(message.find("b").exists()).toBe(false);
120
+ expect(message.text()).toBe("<b>not bold</b>");
121
+ });
122
+
95
123
  it("renders a decorative icon that can be overridden", async () => {
96
124
  wrapper = await wrapperFactory();
97
125
  const icon = wrapper.find(".input-error-message-icon");
@@ -7,14 +7,21 @@ Every colour token below is `var(--input-button-{variant}-{property}, {default})
7
7
  the shared `--theme-*` slot every other themed component (inputs, alerts, ...) also falls back to.
8
8
  See `theming-component-token-pattern.md` for why this two-tier shape exists.
9
9
 
10
- **Resting state is flat by design**: `border` defaults to the variant's own surface colour, so
11
- there's no visible edge until you hover, focus, or override it. **`outline` defaults to fully
12
- transparent** in both resting and hover state — outline is reserved for the focus-visible ring,
13
- not used to reinforce the flat look. **Hover always darkens the resting surface** via `color-mix()`
14
- rather than jumping to a different `--theme-*` step — it's reliably darker in both light and dark
15
- mode. **`:focus-visible` is a separate token set**, not grouped with hover, since it's the one
16
- state that gets a real, independently-themeable indicator (its `outline-width`/`-offset` come from
17
- `--button-focus-ring-width`/`-offset`, not from here — see `theming-form-geometry-tokens.md`).
10
+ The variants follow the usual hierarchy: **primary** is filled, **secondary** is outlined
11
+ (transparent background, border and text in the theme colour), **tertiary** is text-only
12
+ (no background or border until hover), and **inline** has no colours at all, for wrapper
13
+ components that style the button themselves. Primary and secondary also carry a light **shadow ring** (`box-shadow`, 2px of theme step 2)
14
+ outside the border, and on **hover** their 1px outline (no offset) takes the hover border colour,
15
+ so it draws over the inner edge of that ring. Tertiary has neither. The `outline` is transparent
16
+ at rest. **`:focus-visible` is a separate token set**
17
+ (its `outline-width`/`-offset` come from `--button-focus-ring-width`/`-offset`, see
18
+ `theming-form-geometry-tokens.md`).
19
+
20
+ > **Changed 2026-10-17**: secondary was filled with `--theme-surface-inverted`, which read as a
21
+ > darker primary; tertiary was a grey `--slate-01` fill with an underlined label. To bring back
22
+ > the old look for one site, set the secondary or tertiary tokens below (e.g.
23
+ > `--input-button-secondary-surface: var(--theme-surface-inverted)` and
24
+ > `--input-button-secondary-text: var(--theme-text-inverted)`).
18
25
 
19
26
  Geometry and typography (`--button-*`) are separate, already-global tokens with real defaults —
20
27
  see `theming-form-geometry-tokens.md`. Not duplicated here. One geometry token worth calling out:
@@ -32,60 +39,78 @@ the `InlineWithTextInput` story). `box-sizing: content-box` means it's a floor,
32
39
  | `--input-button-primary-ring` | `transparent` | Outline colour (resting) |
33
40
  | `--input-button-primary-surface-hover` | `color-mix(in oklab, surface 85%, black)` | Background on hover |
34
41
  | `--input-button-primary-border-hover` | the resolved hover surface above | Border colour on hover |
35
- | `--input-button-primary-ring-hover` | `transparent` | Outline colour on hover |
42
+ | `--input-button-primary-ring-hover` | the resolved hover border above | Outline colour on hover (1px, no offset) |
43
+ | `--input-button-primary-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Shadow ring colour (all states) |
36
44
  | `--input-button-primary-border-focus` | `var(--theme-border-focus)` | Border colour on `:focus-visible` |
37
45
  | `--input-button-primary-ring-focus` | `var(--theme-ring)` | Outline colour on `:focus-visible` |
38
46
 
39
- ### Secondary
47
+ ### Secondary (outlined)
40
48
 
41
- Same shape, falls back to the inverted-surface slots instead:
49
+ | Token | Falls back to | Controls |
50
+ |---|---|---|
51
+ | `--input-button-secondary-surface` | `transparent` | Background (resting) |
52
+ | `--input-button-secondary-text` | `var(--theme-surface)` | Text colour (all states) |
53
+ | `--input-button-secondary-border` | `var(--theme-surface)` | Border colour (resting) |
54
+ | `--input-button-secondary-ring` | `transparent` | Outline colour (resting) |
55
+ | `--input-button-secondary-ring-hover` | the resolved hover border | Outline colour on hover (1px, no offset) |
56
+ | `--input-button-secondary-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Shadow ring colour (all states) |
57
+ | `--input-button-secondary-surface-hover` | `var(--theme-surface-subtle)` | Background on hover |
58
+ | `--input-button-secondary-border-hover` | the resolved border above | Border colour on hover |
59
+ | `--input-button-secondary-border-focus` | `var(--theme-border-focus)` | Border colour on `:focus-visible` |
60
+ | `--input-button-secondary-ring-focus` | `var(--theme-ring)` | Outline colour on `:focus-visible` |
61
+
62
+ ### Tertiary (text-only)
42
63
 
43
- | Token | Falls back to |
44
- |---|---|
45
- | `--input-button-secondary-surface` | `var(--theme-surface-inverted)` |
46
- | `--input-button-secondary-text` | `var(--theme-text-inverted)` |
47
- | `--input-button-secondary-border` | the resolved surface above (flat) |
48
- | `--input-button-secondary-ring` / `-ring-hover` | `transparent` |
49
- | `--input-button-secondary-surface-hover` | `color-mix(in oklab, surface 85%, black)` |
50
- | `--input-button-secondary-border-hover` | the resolved hover surface above |
51
- | `--input-button-secondary-border-focus` | `var(--theme-border-focus)` |
52
- | `--input-button-secondary-ring-focus` | `var(--theme-ring)` |
64
+ | Token | Falls back to | Controls |
65
+ |---|---|---|
66
+ | `--input-button-tertiary-surface` | `transparent` | Background (resting) |
67
+ | `--input-button-tertiary-text` | `var(--theme-surface)` | Text colour (all states) |
68
+ | `--input-button-tertiary-border` | `transparent` | Border colour (resting) |
69
+ | `--input-button-tertiary-ring` / `-ring-hover` | `transparent` | Outline colour |
70
+ | `--input-button-tertiary-surface-hover` | `var(--theme-surface-subtle)` | Background on hover |
71
+ | `--input-button-tertiary-border-hover` | `transparent` | Border colour on hover |
72
+ | `--input-button-tertiary-border-focus` | `var(--theme-border-focus)` | Border colour on `:focus-visible` |
73
+ | `--input-button-tertiary-ring-focus` | `var(--theme-ring)` | Outline colour on `:focus-visible` |
74
+
75
+ | Token | Default | Controls |
76
+ |---|---|---|
77
+ | `--input-button-shadow-width` | `var(--form-element-shadow-width)` (`0.2rem`) | Width of the primary and secondary shadow ring. Set `0` to remove it from buttons only; set the global token to `0` to remove it from every form control |
53
78
 
54
- ### Tertiary
79
+ The focus ring's offset (`--button-focus-ring-offset`, `0.2rem`) puts it just outside the shadow
80
+ ring; if you widen the ring, raise the offset to match or the focus ring will draw over it. An
81
+ ancestor with `overflow: hidden` and no spare room clips the ring.
55
82
 
56
- Same shape again:
83
+ Secondary and tertiary text sits on the page background, so on a dark section set their `-text`
84
+ and `-border` tokens to a light colour.
57
85
 
58
- | Token | Falls back to |
59
- |---|---|
60
- | `--input-button-tertiary-surface` | `var(--slate-01)` |
61
- | `--input-button-tertiary-text` | `var(--theme-text)` |
62
- | `--input-button-tertiary-border` | the resolved surface above (flat) |
63
- | `--input-button-tertiary-ring` / `-ring-hover` | `transparent` |
64
- | `--input-button-tertiary-surface-hover` | `color-mix(in oklab, surface 85%, black)` |
65
- | `--input-button-tertiary-border-hover` | the resolved hover surface above |
66
- | `--input-button-tertiary-border-focus` | `var(--theme-border-focus)` |
67
- | `--input-button-tertiary-ring-focus` | `var(--theme-ring)` |
68
-
69
- `.is-pending` always dims the *primary* surface regardless of variant — a pre-existing quirk, not
70
- something this token set changes. The animated border added by `has-pending-effect` has its own
71
- `--pending-effect-*` tokens, see `../pending-effect/CONSUMER-STYLING.md`.
86
+ `[data-pending]` dims a primary button's surface to 50%; secondary and tertiary keep their own
87
+ background. The animated border added by `has-pending-effect` has its own `--pending-effect-*`
88
+ tokens, see `../pending-effect/CONSUMER-STYLING.md`.
72
89
 
73
90
  ### Text wrapping
74
91
 
75
92
  | Token | Default | Controls |
76
93
  |---|---|---|
77
- | `--input-button-text-white-space` | `nowrap` | Set `normal` to let a long label wrap onto several lines instead of overflowing the button. Unbroken strings break anywhere once wrapping is on |
94
+ | `--input-button-text-white-space` | `normal` | Long labels wrap onto several lines inside the button, and unbroken strings break anywhere. Set `nowrap` to keep a label on one line; the button then grows to fit it and can overflow a narrow container |
78
95
 
79
- > Added 2026-10-06. `ServicesCardGrid` sets it to `normal` on its CTA buttons so a long service title stays inside the card.
96
+ > Added 2026-10-06. **Changed 2026-10-17**: the default was `nowrap`, which pushed a long label
97
+ > past a narrow column. If you relied on one-line labels in a tight row, set
98
+ > `--input-button-text-white-space: nowrap` on that row.
99
+
100
+ Button text has no line-clamp token on purpose: a cut-off action label can't be trusted.
101
+
102
+ Private tokens (not public API): `--_surface` and `--_surface-hover` (primary's resolved
103
+ surfaces, which its border, hover and contrast colours derive from) and `--_primary-border-hover`, `--_secondary-border` and `--_secondary-border-hover`.
80
104
 
81
105
  `InputCopy` and `PricingCard` fall back through `--input-button-primary-*` for their own copy/CTA
82
106
  buttons (see their own `CONSUMER-STYLING.md`) rather than duplicating this chain.
83
107
 
84
108
  > **Trial, 2026-09-27: automatic text contrast.** In browsers that support CSS `contrast-color()`
85
- > (Safari/iOS 26, Firefox 146, Chrome/Edge 147), an unset `--input-button-{variant}-text` resolves
109
+ > (Safari/iOS 26, Firefox 146, Chrome/Edge 147), an unset `--input-button-primary-text` resolves
86
110
  > to `contrast-color()` of that state's own surface: pure black or pure white, whichever has more
87
111
  > contrast, re-evaluated on hover. Older browsers keep the defaults in the tables above. Setting
88
- > the `-text` token explicitly always wins, so you can opt a variant out. The whole behaviour is
112
+ > the `-text` token explicitly always wins, so you can opt out. Secondary and tertiary aren't part of the trial since
113
+ > 2026-10-17: their surface is transparent. The whole behaviour is
89
114
  > one `@supports` block in `InputButton.vue` and may be removed if the trial doesn't hold up.
90
115
 
91
116
  ---
@@ -101,15 +126,23 @@ buttons (see their own `CONSUMER-STYLING.md`) rather than duplicating this chain
101
126
 
102
127
  | Hook | When |
103
128
  |---|---|
104
- | `.primary`, `.secondary`, `.tertiary`, `.inline` | The `variant` prop |
129
+ | `[data-variant="primary" \| "secondary" \| "tertiary" \| "inline"]` | The `variant` prop |
105
130
  | `[data-theme]` | The `theme` prop (re-declares the `--theme-*` palette on the button) |
106
- | `.icon-only` | The `#iconOnly` slot is used (square, text becomes `sr-only`) |
107
- | `.pill` | `is-pill` |
108
- | `.is-pending`, `.pending-effect` | `is-pending` / `has-pending-effect` |
109
- | `.is-link` | `href` is set (renders `NuxtLink` or `<a>`) |
131
+ | `[data-icon-only]` | The `#iconOnly` slot is used (square, text becomes `sr-only`) |
132
+ | `[data-pill]` | `is-pill` |
133
+ | `[data-pending]`, `[data-pending-effect]` | `is-pending` / `has-pending-effect` |
134
+ | `[data-link]` | `href` is set (renders `NuxtLink` or `<a>`) |
110
135
  | `[readonly]` + `aria-disabled="true"` | `readonly` (dimmed, not operable by mouse or keyboard) |
111
136
 
112
- Inner classes: `.button-text`, `.btn-icon` with `.left` / `.right` / `.icon-only`.
137
+ Inner elements: `.input-button-text`, and `.input-button-icon` with `data-slot="left"`,
138
+ `"right"` or `"icon-only"`.
139
+
140
+ > **Changed 2026-10-17**: these were bare classes, which collided with consumer CSS. Update
141
+ > selectors: `.input-button.primary` → `.input-button[data-variant="primary"]`, `.pill` →
142
+ > `[data-pill]`, `.is-pending` → `[data-pending]`, `.pending-effect` → `[data-pending-effect]`,
143
+ > `.is-link` → `[data-link]`, `.icon-only` → `[data-icon-only]`, `.button-text` →
144
+ > `.input-button-text`, `.btn-icon.left` → `.input-button-icon[data-slot="left"]`. Same
145
+ > specificity, so overrides keep winning where they did.
113
146
 
114
147
 
115
148
  ## Global theming