srcdev-nuxt-components 9.6.10 → 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 (108) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +4 -2
  2. package/.claude/skills/component-compliance-checklist.md +9 -3
  3. package/.claude/skills/component-inline-action-button.md +5 -4
  4. package/.claude/skills/component-naming.md +2 -1
  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/index.md +5 -5
  26. package/.claude/skills/storybook-add-story.md +3 -1
  27. package/.claude/skills/theming-component-token-pattern.md +24 -0
  28. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  29. package/.claude/skills/theming-partial-override.md +1 -1
  30. package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
  31. package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
  32. package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
  33. package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
  34. package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
  35. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
  36. package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
  37. package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
  38. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
  39. package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
  40. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
  41. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
  42. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
  43. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
  44. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
  45. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
  46. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
  47. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
  48. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
  49. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
  50. package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
  51. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
  52. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
  53. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
  54. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
  55. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
  56. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
  57. package/app/components/05.forms/form-errors/InputError.vue +43 -37
  58. package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
  59. package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
  60. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
  61. package/app/components/05.forms/input-button/InputButton.vue +52 -100
  62. package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
  63. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
  64. package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
  65. package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
  66. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
  67. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
  68. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
  69. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
  70. package/app/components/05.forms/input-description/InputDescription.vue +2 -1
  71. package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
  72. package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
  73. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +8 -0
  74. package/app/components/05.forms/input-label/InputLabel.vue +1 -0
  75. package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
  76. package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
  77. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
  78. package/app/components/05.forms/input-number/InputNumber.vue +4 -5
  79. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
  80. package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
  81. package/app/components/05.forms/input-range/InputRange.vue +1 -1
  82. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
  83. package/app/components/05.forms/input-select/InputSelect.vue +14 -5
  84. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
  85. package/app/components/05.forms/input-text/InputText.vue +4 -4
  86. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
  87. package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
  88. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
  89. package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
  90. package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
  91. package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
  92. package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
  93. package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
  94. package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
  95. package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
  96. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
  97. package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
  98. package/app/types/components/index.ts +1 -1
  99. package/package.json +1 -1
  100. package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
  101. package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
  102. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
  103. package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
  104. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
  105. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
  106. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
  107. package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
  108. /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
@@ -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
@@ -4,23 +4,29 @@
4
4
  :type="!isLink ? props.type : undefined"
5
5
  :href="isLink ? props.href : undefined"
6
6
  :readonly="!isLink ? props.readonly : undefined"
7
- :aria-disabled="!isLink ? props.readonly : undefined"
7
+ :aria-disabled="!isLink && props.readonly ? 'true' : undefined"
8
8
  data-testid="input-button"
9
9
  :data-theme="theme"
10
+ :data-variant="variant"
11
+ :data-pill="isPill ? '' : undefined"
12
+ :data-pending="isPending ? '' : undefined"
13
+ :data-pending-effect="hasPendingEffect ? '' : undefined"
14
+ :data-link="isLink ? '' : undefined"
15
+ :data-icon-only="hasIconOnlySlot() ? '' : undefined"
10
16
  class="input-button"
11
- :class="buttonClasses()"
17
+ :class="elementClasses"
12
18
  @click.capture="guardReadonly"
13
19
  >
14
20
  <PendingEffect v-if="hasPendingEffect" :theme="theme" />
15
21
 
16
- <span v-if="hasLeftSlot()" class="btn-icon left">
22
+ <span v-if="hasLeftSlot()" class="input-button-icon" data-slot="left">
17
23
  <slot name="left"></slot>
18
24
  </span>
19
- <span class="button-text" :class="[{ 'sr-only': hasIconOnlySlot() }]">{{ buttonText }}</span>
20
- <span v-if="hasRightSlot()" class="btn-icon right">
25
+ <span class="input-button-text" :class="{ 'sr-only': hasIconOnlySlot() }">{{ buttonText }}</span>
26
+ <span v-if="hasRightSlot()" class="input-button-icon" data-slot="right">
21
27
  <slot name="right"></slot>
22
28
  </span>
23
- <span v-if="hasIconOnlySlot()" class="btn-icon icon-only">
29
+ <span v-if="hasIconOnlySlot()" class="input-button-icon" data-slot="icon-only">
24
30
  <slot name="iconOnly"></slot>
25
31
  </span>
26
32
  </component>
@@ -77,22 +83,10 @@ const tag = computed(() => {
77
83
  return "button";
78
84
  });
79
85
 
80
- // Cache slot computations for better performance
81
86
  const hasLeftSlot = () => Boolean(slots.left && !slots.iconOnly);
82
87
  const hasRightSlot = () => Boolean(slots.right && !slots.iconOnly);
83
88
  const hasIconOnlySlot = () => Boolean(slots.iconOnly);
84
89
 
85
- // Combine all button classes into a single computed
86
- const buttonClasses = () => [
87
- props.variant,
88
- elementClasses.value,
89
- { "icon-only": hasIconOnlySlot() },
90
- { "pending-effect": props.hasPendingEffect },
91
- { "is-pending": props.isPending },
92
- { pill: props.isPill },
93
- { "is-link": isLink.value },
94
- ];
95
-
96
90
  const guardReadonly = (event: MouseEvent) => {
97
91
  if (!props.readonly) return;
98
92
  event.preventDefault();
@@ -128,47 +122,32 @@ watch(
128
122
  overflow: hidden;
129
123
  transition: all var(--control-transition-duration) var(--control-transition-ease);
130
124
 
131
- &.is-link {
125
+ &[data-link] {
132
126
  display: inline-grid;
133
127
  }
134
128
 
135
- &.pending-effect {
129
+ &[data-pending-effect] {
136
130
  overflow: visible;
137
131
  }
138
132
 
139
- /*
140
- * Theming
141
- * Every colour is a public --input-button-{variant}-{property} token, inline-fallback to
142
- * the shared theme slot (see theming-component-token-pattern.md for why: overriding one
143
- * variant here never touches the --theme-* slots other components read).
144
- *
145
- * Flat by default: border defaults to the variant's own surface colour (so the resting state
146
- * shows no visible edge) and outline defaults to fully transparent — outline is reserved for
147
- * the focus-visible ring, not used to reinforce the flat look. Hover darkens the surface via
148
- * color-mix (always darker, regardless of which direction the underlying theme ramp runs in
149
- * light vs dark mode — no more "hover looks lighter" surprises) and carries the border along
150
- * with it, still flat; outline stays transparent through hover too.
151
- * focus-visible is deliberately its own block, not grouped with :hover — it's the one state
152
- * that gets a real, separately-themeable a11y indicator, and its outline-width/-offset (set
153
- * below, in Shared States) give it a visible ring regardless of the border/outline being
154
- * invisible everywhere else.
155
- * --_surface is a private local (not a public token) — it exists purely so -hover/-border/-ring
156
- * can color-mix() off the SAME resolved value a consumer's --input-button-{variant}-surface
157
- * override produces, without every derived token repeating that fallback chain itself.
158
- **/
159
- &.primary {
133
+ /* Primary is filled, secondary outlined, tertiary text-only; inline has no colours (wrappers style it).
134
+ Primary and secondary carry a light shadow ring; hover draws the outline over its inner edge. */
135
+ &[data-variant="primary"] {
160
136
  --_surface: var(--input-button-primary-surface, var(--theme-surface));
161
137
  --_surface-hover: var(--input-button-primary-surface-hover, color-mix(in oklab, var(--_surface) 85%, black));
138
+ --_primary-border-hover: var(--input-button-primary-border-hover, var(--_surface-hover));
162
139
 
163
140
  background-color: var(--_surface);
164
141
  color: var(--input-button-primary-text, var(--theme-on-surface));
165
142
  border: var(--button-border-width) solid var(--input-button-primary-border, var(--_surface));
166
143
  outline: var(--button-outline-width) solid var(--input-button-primary-ring, transparent);
144
+ box-shadow: 0 0 0 var(--input-button-shadow-width, var(--form-element-shadow-width))
145
+ var(--input-button-primary-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
167
146
 
168
147
  &:hover {
169
148
  background-color: var(--_surface-hover);
170
- border-color: var(--input-button-primary-border-hover, var(--_surface-hover));
171
- outline-color: var(--input-button-primary-ring-hover, transparent);
149
+ border-color: var(--_primary-border-hover);
150
+ outline-color: var(--input-button-primary-ring-hover, var(--_primary-border-hover));
172
151
  }
173
152
 
174
153
  &:focus-visible {
@@ -177,9 +156,7 @@ watch(
177
156
  }
178
157
  }
179
158
 
180
- &.is-pending {
181
- /* Always the primary surface regardless of variant — pre-existing behaviour, not
182
- something this migration changes. */
159
+ &[data-pending] {
183
160
  background-color: color-mix(
184
161
  in oklab,
185
162
  var(--input-button-primary-surface, var(--theme-surface)) 50%,
@@ -187,22 +164,21 @@ watch(
187
164
  );
188
165
  }
189
166
 
190
- &.secondary {
191
- --_surface: var(--input-button-secondary-surface, var(--theme-surface-inverted));
192
- --_surface-hover: var(
193
- --input-button-secondary-surface-hover,
194
- color-mix(in oklab, var(--_surface) 85%, black)
195
- );
167
+ &[data-variant="secondary"] {
168
+ --_secondary-border: var(--input-button-secondary-border, var(--theme-surface));
169
+ --_secondary-border-hover: var(--input-button-secondary-border-hover, var(--_secondary-border));
196
170
 
197
- background-color: var(--_surface);
198
- color: var(--input-button-secondary-text, var(--theme-text-inverted));
199
- border: var(--button-border-width) solid var(--input-button-secondary-border, var(--_surface));
171
+ background-color: var(--input-button-secondary-surface, transparent);
172
+ color: var(--input-button-secondary-text, var(--theme-surface));
173
+ border: var(--button-border-width) solid var(--_secondary-border);
200
174
  outline: var(--button-outline-width) solid var(--input-button-secondary-ring, transparent);
175
+ box-shadow: 0 0 0 var(--input-button-shadow-width, var(--form-element-shadow-width))
176
+ var(--input-button-secondary-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
201
177
 
202
178
  &:hover {
203
- background-color: var(--_surface-hover);
204
- border-color: var(--input-button-secondary-border-hover, var(--_surface-hover));
205
- outline-color: var(--input-button-secondary-ring-hover, transparent);
179
+ background-color: var(--input-button-secondary-surface-hover, var(--theme-surface-subtle));
180
+ border-color: var(--_secondary-border-hover);
181
+ outline-color: var(--input-button-secondary-ring-hover, var(--_secondary-border-hover));
206
182
  }
207
183
 
208
184
  &:focus-visible {
@@ -211,22 +187,15 @@ watch(
211
187
  }
212
188
  }
213
189
 
214
- &.tertiary {
215
- --_surface: var(--input-button-tertiary-surface, var(--slate-01));
216
- --_surface-hover: var(
217
- --input-button-tertiary-surface-hover,
218
- color-mix(in oklab, var(--_surface) 85%, black)
219
- );
220
-
221
- background-color: var(--_surface);
222
- color: var(--input-button-tertiary-text, var(--theme-text));
223
- border: var(--button-border-width) solid var(--input-button-tertiary-border, var(--_surface));
190
+ &[data-variant="tertiary"] {
191
+ background-color: var(--input-button-tertiary-surface, transparent);
192
+ color: var(--input-button-tertiary-text, var(--theme-surface));
193
+ border: var(--button-border-width) solid var(--input-button-tertiary-border, transparent);
224
194
  outline: var(--button-outline-width) solid var(--input-button-tertiary-ring, transparent);
225
- text-decoration: underline;
226
195
 
227
196
  &:hover {
228
- background-color: var(--_surface-hover);
229
- border-color: var(--input-button-tertiary-border-hover, var(--_surface-hover));
197
+ background-color: var(--input-button-tertiary-surface-hover, var(--theme-surface-subtle));
198
+ border-color: var(--input-button-tertiary-border-hover, transparent);
230
199
  outline-color: var(--input-button-tertiary-ring-hover, transparent);
231
200
  }
232
201
 
@@ -236,38 +205,21 @@ watch(
236
205
  }
237
206
  }
238
207
 
239
- /* Trial (2026-09-27): where supported, text picks black or white from its own surface, per
240
- state. An explicit --input-button-{variant}-text still wins. Remove this block to revert. */
208
+ /* Trial (2026-09-27): primary text picks black or white from its own surface. Remove to revert. */
241
209
  @supports (color: contrast-color(red)) {
242
- &.primary {
210
+ &[data-variant="primary"] {
243
211
  color: var(--input-button-primary-text, contrast-color(var(--_surface)));
244
212
 
245
213
  &:hover {
246
214
  color: var(--input-button-primary-text, contrast-color(var(--_surface-hover)));
247
215
  }
248
216
  }
249
-
250
- &.secondary {
251
- color: var(--input-button-secondary-text, contrast-color(var(--_surface)));
252
-
253
- &:hover {
254
- color: var(--input-button-secondary-text, contrast-color(var(--_surface-hover)));
255
- }
256
- }
257
-
258
- &.tertiary {
259
- color: var(--input-button-tertiary-text, contrast-color(var(--_surface)));
260
-
261
- &:hover {
262
- color: var(--input-button-tertiary-text, contrast-color(var(--_surface-hover)));
263
- }
264
- }
265
217
  }
266
218
 
267
- &.primary,
268
- &.secondary,
269
- &.tertiary {
270
- &.pill {
219
+ &[data-variant="primary"],
220
+ &[data-variant="secondary"],
221
+ &[data-variant="tertiary"] {
222
+ &[data-pill] {
271
223
  border-radius: 100vw;
272
224
  }
273
225
  }
@@ -290,10 +242,10 @@ watch(
290
242
  pointer-events: none;
291
243
  }
292
244
 
293
- .button-text {
245
+ & .input-button-text {
294
246
  display: inline-block;
295
247
  min-inline-size: 0;
296
- white-space: var(--input-button-text-white-space, nowrap);
248
+ white-space: var(--input-button-text-white-space, normal);
297
249
  overflow-wrap: anywhere;
298
250
  font-size: var(--button-font-size);
299
251
  line-height: var(--button-line-height);
@@ -301,22 +253,22 @@ watch(
301
253
  text-transform: var(--button-text-transform);
302
254
  }
303
255
 
304
- .btn-icon {
256
+ & .input-button-icon {
305
257
  display: flex;
306
258
 
307
- .icon {
259
+ & .icon {
308
260
  display: inline-block;
309
261
  font-size: var(--input-icon-size);
310
262
  }
311
263
  }
312
264
 
313
- &.icon-only {
265
+ &[data-icon-only] {
314
266
  aspect-ratio: 1;
315
267
  border-radius: var(--button-border-radius-icon-only);
316
268
  margin: 0;
317
269
  padding: 0;
318
270
 
319
- .btn-icon {
271
+ & .input-button-icon {
320
272
  margin: 1.2rem;
321
273
  }
322
274
  }
@@ -2,7 +2,7 @@ import { test, expect, type Page } from "@playwright/test";
2
2
 
3
3
  // --- Config ---
4
4
  const STORYBOOK_URL = "http://127.0.0.1:6006";
5
- const STORY_BASE = "components-forms-input-button-inputbuttoncore";
5
+ const STORY_BASE = "components-forms-input-button-inputbutton";
6
6
  const BUTTON_TIMEOUT = 15000;
7
7
 
8
8
  // --- Helpers ---