srcdev-nuxt-components 9.3.12 → 9.3.13

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 (49) hide show
  1. package/.claude/skills/index.md +1 -0
  2. package/.claude/skills/theming-component-token-pattern.md +126 -0
  3. package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +10 -1
  4. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +3 -3
  5. package/app/components/02.molecules/input-copy/InputCopy.vue +9 -3
  6. package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +3 -3
  7. package/app/components/02.molecules/pricing-card/PricingCard.vue +6 -3
  8. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +117 -0
  9. package/app/components/05.forms/input-button/InputButtonCore.vue +83 -37
  10. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-secondary-chromium-darwin.png +0 -0
  11. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-tertiary-chromium-darwin.png +0 -0
  12. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-primary-pending-chromium-darwin.png +0 -0
  13. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-secondary-pill-chromium-darwin.png +0 -0
  14. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-tertiary-readonly-chromium-darwin.png +0 -0
  15. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-chromium-darwin.png +0 -0
  16. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-default-chromium-darwin.png +0 -0
  17. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-error-chromium-darwin.png +0 -0
  18. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-success-chromium-darwin.png +0 -0
  19. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-warning-chromium-darwin.png +0 -0
  20. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-primary-chromium-darwin.png +0 -0
  21. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-secondary-chromium-darwin.png +0 -0
  22. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-tertiary-chromium-darwin.png +0 -0
  23. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/nuxt-icon-only-component-chromium-darwin.png +0 -0
  24. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pending-chromium-darwin.png +0 -0
  25. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pill-chromium-darwin.png +0 -0
  26. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-readonly-chromium-darwin.png +0 -0
  27. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/with-both-nuxt-icon-components-chromium-darwin.png +0 -0
  28. package/app/components/05.forms/input-button/stories/InputButtonCore.stories.ts +35 -0
  29. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +68 -0
  30. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +19 -9
  31. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioCore.vue +8 -5
  32. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +93 -0
  33. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioCore.stories.ts +75 -0
  34. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +41 -0
  35. package/app/components/05.forms/input-number/InputNumberCore.vue +12 -6
  36. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +35 -29
  37. package/app/components/05.forms/input-select/InputSelectCore.vue +32 -20
  38. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +69 -0
  39. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +38 -48
  40. package/app/components/05.forms/input-text/InputTextCore.vue +35 -18
  41. package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +184 -0
  42. package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +125 -0
  43. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +49 -0
  44. package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +23 -16
  45. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +72 -0
  46. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +42 -20
  47. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitchCore.stories.ts +63 -0
  48. package/app/components/display-theme-switch/DisplayThemeSwitch.vue +13 -6
  49. package/package.json +1 -1
@@ -32,6 +32,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
32
32
  ├── theming-override-default.md — replace the entire default theme with a custom palette (set --theme-hue/--theme-chroma)
33
33
  ├── theming-partial-override.md — override a specific colour token category (palette, buttons, inputs) without a full theme replacement
34
34
  ├── theming-form-geometry-tokens.md — non-colour form/button/input tokens (padding, border-radius, gaps): full inventory, why partial override doesn't need duplicating the source files
35
+ ├── theming-component-token-pattern.md — standard shape for overridable component colours: public token + inline fallback, no bare var()/no private-only indirection; the three tiers (theme slots / cross-component families / component-scoped); rollout status
35
36
  ├── colour-scheme-disable.md — disable light/dark scheme support in a consumer app
36
37
  ├── component-dynamic-slots.md — named dynamic slots ($slots iteration) vs indexed dynamic slots (itemCount pattern)
37
38
  ├── component-local-style-override.md — styleClassPassthrough + scoped style block for per-usage visual customisation
@@ -0,0 +1,126 @@
1
+ # Component Token Pattern — Public Token + Inline Fallback
2
+
3
+ ## Overview
4
+
5
+ The standard shape for every overridable CSS value in a component, from now on:
6
+
7
+ ```css
8
+ background-color: var(--{component}-{property}, {real-default-or-shared-fallback});
9
+ ```
10
+
11
+ One name, declared inline at the point of use, with a real default right there in the component
12
+ source. No separate "public token must be declared somewhere else or the component breaks"
13
+ requirement, and no private `--_` indirection standing in for the actual override point.
14
+ `ServicesCard.vue` is the reference implementation — read its `<style>` block for the shape in
15
+ practice.
16
+
17
+ ## Why this replaced the old pattern
18
+
19
+ Two things used to go wrong, both found while auditing `luxury-locs-by-natasha`'s theming
20
+ overrides (2026-08-25):
21
+
22
+ 1. **Bare `var(--theme-checkbox-symbol-color)` with no fallback anywhere.** The only way for this
23
+ to resolve to something was a consuming app declaring it globally. When the component was later
24
+ refactored (buttons moved onto the shared theme-slot system) and stopped reading the token, nothing
25
+ signalled that to the consumer — the app's declaration became permanently dead, silently. A
26
+ `var()` with an undefined custom property and no fallback just resolves to the property's
27
+ initial value; there is no error, no warning, ever.
28
+ 2. **Tokens that were "shared" only by accident.** `InputTextCore`, `InputSelectCore`,
29
+ `InputNumberCore` and the checkbox button all read the exact same `--theme-input-surface`, so a
30
+ consumer wanting to restyle just text inputs had no way to do that without also restyling every
31
+ other input type. The private `--_input-text-surface: var(--theme-input-surface);` indirection
32
+ made this look like it had its own override point when it didn't.
33
+
34
+ ## The three tiers
35
+
36
+ | Tier | Example | No-fallback allowed? |
37
+ |---|---|---|
38
+ | **1 — theme slots** | `--theme-surface`, `--theme-border`, `--theme-ring`, `--theme-text`, ... (`_theme-slots.css`) | Yes — deliberately global, small, stable set. Changing one is meant to reskin everything that shares it. |
39
+ | **2 — cross-component families** | `--theme-input-surface`, `--theme-checkbox-symbol-surface` | No longer created new. Existing ones stay as the *fallback value* inside Tier 3 tokens, not as the thing components read directly. |
40
+ | **3 — component-scoped** | `--input-button-primary-surface`, `--services-card-border-colour` | Never — always `var(--{component}-token, <fallback>)`. |
41
+
42
+ Tier 1 is the one deliberate exception. Everything else gets a real component-prefixed name with
43
+ an inline fallback, even if that fallback happens to be a Tier 1 or Tier 2 token underneath.
44
+
45
+ ## Naming convention
46
+
47
+ `--{component-kebab-name}-{variant-if-any}-{property}[-state]`
48
+
49
+ - `--input-button-primary-surface` / `-surface-hover` — variant + property + state
50
+ - `--services-card-border-colour` / `-border-colour-hover` — property + state
51
+ - Only add a `-hover`/`-focus`/`-active` suffix if that state's value actually differs from the
52
+ base token. If hover reuses the exact same value as base (see `InputButtonCore`'s primary text
53
+ colour, fixed on purpose across states), reuse the base token — don't manufacture a second name
54
+ for a value that never diverges.
55
+
56
+ ## Multi-level fallback chains are fine
57
+
58
+ A component that wants to *look like* another component's default (rather than duplicating a
59
+ raw value) chains through it:
60
+
61
+ ```css
62
+ /* InputCopy's copy button defaults to InputButtonCore's primary look */
63
+ --_button-bg: var(--input-copy-button-bg, var(--input-button-primary-surface, var(--theme-surface)));
64
+ ```
65
+
66
+ Three tiers deep here: instance/global override → "look like the primary button" → the theme
67
+ slot underneath that. Each link in the chain is a real, resolvable token — nothing depends on a
68
+ consuming app having declared anything.
69
+
70
+ ## What this does NOT change
71
+
72
+ - Tier 1 theme slots (`_theme-slots.css`) stay bare `var()`, no fallback, by design.
73
+ - Geometry/typography tokens that already have real component-local defaults
74
+ (`--button-padding-block: 1rem;` in `06.button-geometry.css`) were never part of this problem —
75
+ they're declared with real values already, just not inline at the point of use. Leave them.
76
+
77
+ ## Rollout status
78
+
79
+ - ✅ `InputButtonCore` (primary/secondary/tertiary), `InputCopy`, `PricingCard` — migrated
80
+ 2026-08-25, see their `CONSUMER-STYLING.md` for the full token list. This pass also included a
81
+ deliberate visual redesign (flat borders, `color-mix()`-darkened hover, transparent resting
82
+ outline, separated `:focus-visible`), not just the token API change.
83
+ - ✅ `InputCheckboxRadioCore`/`Button` — migrated 2026-08-25, see their `CONSUMER-STYLING.md`.
84
+ Pure token-rename pass, no visual/default changes (unlike `InputButtonCore`'s redesign above).
85
+ Added Storybook stories for both (`InputCheckboxRadioCore`, `InputCheckboxRadioButton`) —
86
+ neither had one before.
87
+ - ✅ `InputTextCore`, `InputSelectCore`, `InputNumberCore` — migrated 2026-08-25, see their
88
+ `CONSUMER-STYLING.md`. `InputTextCore`/`InputSelectCore` also had this exact problem's original
89
+ form: a private `--_input-text-*`/`--_input-select-*` naming scheme documented as a second
90
+ "local override path" alongside the global tokens, added 2026-08-22 (three days before this
91
+ pattern was formalised) as a workaround for a global-override-not-landing report that was never
92
+ root-caused. That private-var-as-override-point shape is exactly what this pattern replaces —
93
+ renamed public with real fallbacks, old two-path docs removed. `InputNumberCore` had no
94
+ component-scoped tokens at all before this (bare `--theme-input-*` throughout). Added a
95
+ Storybook story for `InputSelectCore` (didn't have one); `InputNumberCore` still doesn't.
96
+ `InputTextCore`/`InputSelectCore`'s `.normal` variant also had hover and `:focus-visible` share
97
+ one token (literally the same rule content duplicated under two selectors) — split into
98
+ `--input-text-border-hover`/`--input-select-border-hover` alongside the existing `-border-focus`
99
+ pair, both still defaulting to `--theme-border-focus` so default rendering is unchanged, but
100
+ mouse and assistive-tech interaction can now be themed independently. `InputNumberCore` has no
101
+ hover state on its border at all (only a `:focus-visible` box-shadow toggle), so there was
102
+ nothing to split there.
103
+ - ✅ `ToggleSwitchCore`, `TripleToggleSwitchCore`, `DisplayThemeSwitch` — migrated 2026-08-25, see
104
+ the first two's `CONSUMER-STYLING.md`. Correction to the two lines this replaces: they claimed
105
+ `ToggleSwitchCore` read a bare `--theme-toggle-symbol-color-*` — that token doesn't exist
106
+ anywhere in this codebase and never did; false memory from conflating it with the checkbox
107
+ fix. `ToggleSwitchCore` itself turned out to be a plain Tier 2 case (real, live tokens, just no
108
+ component-scoped override point) plus five confirmed-dead private locals (declared, never read
109
+ anywhere in the file) removed outright. The real dead-token bug was in `TripleToggleSwitchCore`
110
+ (and duplicated in `DisplayThemeSwitch`'s own override): `--theme-form-radio-border`,
111
+ `--theme-form-radio-outline`, `--theme-form-checkbox-bg` — all three declared nowhere in the
112
+ layer. The first two fed two further-unused private locals (removed); the third fed the
113
+ selected-option marker's background (real bug, narrow impact — only visible when none of the
114
+ component's `system`/`light`/`dark` `:has()` overrides match).
115
+
116
+ Second bug, found while adding the Storybook story `TripleToggleSwitchCore` never had: those
117
+ `:has()` selectors (and the matching `.option-icon.system/.light/.dark` classes, template-bound
118
+ to each option's `id`) said `"auto"`, not `"system"` — but the only known consumer,
119
+ `DisplayThemeSwitch`, has only ever emitted `id`/`value: "system"` (it has to; that literal
120
+ string is `useSettingsStore.setColourScheme`'s type and the CSS class it applies to `<html>`).
121
+ `"auto"` never matched anything real, so the system option's green gradient marker had never
122
+ actually fired — confirmed live before and after the fix. Renamed the selectors to `"system"`
123
+ rather than the data, since the data value is a real external contract this component's
124
+ presentational selectors have no business dictating. `TripleToggleSwitchCore` still hardcodes
125
+ three option values as CSS selectors rather than being genuinely value-agnostic — noted in its
126
+ `CONSUMER-STYLING.md`, left as a larger separate change.
@@ -13,5 +13,14 @@
13
13
 
14
14
  --button-icon-gap: 1.8rem;
15
15
 
16
- /* --button-min-height: 4rem; */
16
+ /* Sized so the button's TOTAL rendered height matches --input-min-height, not its content
17
+ box alone — .input-button-core keeps box-sizing: content-box (changing that risks shifting
18
+ icon-only's aspect-ratio: 1 sizing), so its own padding-block and border have to be
19
+ subtracted back out here, unlike --input-min-height itself which happens to need no such
20
+ adjustment (InputTextCore has zero padding-block and no full border in its resting state).
21
+ This is a floor, not a fixed height — icon-only/pill buttons or longer button-text can
22
+ still grow past it. Textareas are the deliberate exception to this alignment entirely. */
23
+ --button-min-height: calc(
24
+ var(--input-min-height) - (2 * var(--button-padding-block)) - (2 * var(--button-border-width))
25
+ );
17
26
  }
@@ -14,9 +14,9 @@ or instance) without touching the component itself.
14
14
  | `--input-copy-input-text-color` | `#333` | Input text colour |
15
15
  | `--input-copy-input-placeholder-color` | `#999` | Placeholder text colour |
16
16
  | `--input-copy-input-font-size` | `0.95rem` | Input field font size |
17
- | `--input-copy-button-bg` | `var(--theme-button-primary-surface)` | Copy button background |
18
- | `--input-copy-button-bg-hover` | `var(--theme-button-primary-surface-hover)` | Copy button background on hover |
19
- | `--input-copy-button-text-color` | `var(--theme-button-primary-text)` | Copy button text colour |
17
+ | `--input-copy-button-bg` | `var(--input-button-primary-surface)` | Copy button background |
18
+ | `--input-copy-button-bg-hover` | `var(--input-button-primary-surface-hover)` | Copy button background on hover |
19
+ | `--input-copy-button-text-color` | `var(--input-button-primary-text)` | Copy button text colour |
20
20
  | `--input-copy-button-padding` | `0.8rem 1.2rem` | Copy button padding |
21
21
  | `--input-copy-button-border-radius` | `0.4rem` | Copy button corner rounding |
22
22
  | `--input-copy-button-gap` | `0.6rem` | Gap between icon and text in button |
@@ -102,9 +102,15 @@ onUnmounted(() => {
102
102
  --_input-placeholder-color: var(--input-copy-input-placeholder-color, #999);
103
103
  --_input-font-size: var(--input-copy-input-font-size, 0.95rem);
104
104
 
105
- --_button-bg: var(--input-copy-button-bg, var(--theme-button-primary-surface));
106
- --_button-bg-hover: var(--input-copy-button-bg-hover, var(--theme-button-primary-surface-hover));
107
- --_button-text-color: var(--input-copy-button-text-color, var(--theme-button-primary-text));
105
+ --_button-bg: var(--input-copy-button-bg, var(--input-button-primary-surface, var(--theme-surface)));
106
+ --_button-bg-hover: var(
107
+ --input-copy-button-bg-hover,
108
+ var(--input-button-primary-surface-hover, var(--theme-surface-hover))
109
+ );
110
+ --_button-text-color: var(
111
+ --input-copy-button-text-color,
112
+ var(--input-button-primary-text, var(--theme-on-surface))
113
+ );
108
114
  --_button-padding: var(--input-copy-button-padding, 0.8rem 1.2rem);
109
115
  --_button-border-radius: var(--input-copy-button-border-radius, 0.4rem);
110
116
  --_button-gap: var(--input-copy-button-gap, 0.6rem);
@@ -33,9 +33,9 @@ override it — there is no locale-aware formatting, just a literal prefix chara
33
33
  | `--pricing-card-description-color` | `#555` | Plan description text colour |
34
34
  | `--pricing-card-feature-color` | `#333` | Feature list text colour |
35
35
  | `--pricing-card-cta-align` | `flex-start` | CTA wrapper `align-self` — set to `stretch`, `center`, or `flex-end` to change CTA alignment/width within the card |
36
- | `--pricing-card-cta-bg` | `var(--theme-button-primary-surface)` | CTA button background |
37
- | `--pricing-card-cta-bg-hover` | `var(--theme-button-primary-surface-hover)` | CTA button background on hover |
38
- | `--pricing-card-cta-text` | `var(--theme-button-primary-text)` | CTA button text colour |
36
+ | `--pricing-card-cta-bg` | `var(--input-button-primary-surface)` | CTA button background |
37
+ | `--pricing-card-cta-bg-hover` | `var(--input-button-primary-surface-hover)` | CTA button background on hover |
38
+ | `--pricing-card-cta-text` | `var(--input-button-primary-text)` | CTA button text colour |
39
39
  | `--pricing-card-cta-padding` | `1rem 1.6rem` | CTA button padding |
40
40
  | `--pricing-card-cta-border-radius` | `0.4rem` | CTA button corner rounding |
41
41
 
@@ -111,9 +111,12 @@ watch(
111
111
  --_feature-color: var(--pricing-card-feature-color, #333);
112
112
 
113
113
  --_cta-align: var(--pricing-card-cta-align, flex-start);
114
- --_cta-bg: var(--pricing-card-cta-bg, var(--theme-button-primary-surface));
115
- --_cta-bg-hover: var(--pricing-card-cta-bg-hover, var(--theme-button-primary-surface-hover));
116
- --_cta-text: var(--pricing-card-cta-text, var(--theme-button-primary-text));
114
+ --_cta-bg: var(--pricing-card-cta-bg, var(--input-button-primary-surface, var(--theme-surface)));
115
+ --_cta-bg-hover: var(
116
+ --pricing-card-cta-bg-hover,
117
+ var(--input-button-primary-surface-hover, var(--theme-surface-hover))
118
+ );
119
+ --_cta-text: var(--pricing-card-cta-text, var(--input-button-primary-text, var(--theme-on-surface)));
117
120
  --_cta-padding: var(--pricing-card-cta-padding, 1rem 1.6rem);
118
121
  --_cta-border-radius: var(--pricing-card-cta-border-radius, 0.4rem);
119
122
 
@@ -0,0 +1,117 @@
1
+ # InputButtonCore — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ Every colour token below is `var(--input-button-{variant}-{property}, {default})` — set the
6
+ `--input-button-*` name to override just this component's variant; leave it unset and it inherits
7
+ the shared `--theme-*` slot every other themed component (inputs, alerts, ...) also falls back to.
8
+ See `theming-component-token-pattern.md` for why this two-tier shape exists.
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`).
18
+
19
+ Geometry and typography (`--button-*`) are separate, already-global tokens with real defaults —
20
+ see `theming-form-geometry-tokens.md`. Not duplicated here. One geometry token worth calling out:
21
+ `--button-min-height` defaults to `var(--input-min-height)`, so a button lines up height-wise with
22
+ a text input/select/number field placed next to it (e.g. an inline "email + submit" pattern — see
23
+ the `InlineWithTextInput` story). `box-sizing: content-box` means it's a floor, not a fixed height.
24
+
25
+ ### Primary
26
+
27
+ | Token | Falls back to | Controls |
28
+ |---|---|---|
29
+ | `--input-button-primary-surface` | `var(--theme-surface)` | Background (resting) |
30
+ | `--input-button-primary-text` | `var(--theme-on-surface)` | Text colour (all states) |
31
+ | `--input-button-primary-border` | the resolved surface above | Border colour (resting) — flat by default |
32
+ | `--input-button-primary-ring` | `transparent` | Outline colour (resting) |
33
+ | `--input-button-primary-surface-hover` | `color-mix(in oklab, surface 85%, black)` | Background on hover |
34
+ | `--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 |
36
+ | `--input-button-primary-border-focus` | `var(--theme-border-focus)` | Border colour on `:focus-visible` |
37
+ | `--input-button-primary-ring-focus` | `var(--theme-ring)` | Outline colour on `:focus-visible` |
38
+
39
+ ### Secondary
40
+
41
+ Same shape, falls back to the inverted-surface slots instead:
42
+
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)` |
53
+
54
+ ### Tertiary
55
+
56
+ Same shape again; resting surface is transparent by default in dark mode (see note below):
57
+
58
+ | Token | Falls back to |
59
+ |---|---|
60
+ | `--input-button-tertiary-surface` | `light-dark(var(--slate-01), transparent)` |
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
+ > `--input-button-tertiary-surface`'s dark-mode default is literal `transparent`. `color-mix()`
70
+ > still produces a correct darker hover from that — mixing `transparent` with `black` lowers the
71
+ > alpha channel rather than the lightness, so hover reads as a faint dark tint over whatever's
72
+ > behind the button, not a flat fill swap.
73
+
74
+ `.is-pending` always dims the *primary* surface regardless of variant — a pre-existing quirk, not
75
+ something this token set changes.
76
+
77
+ `InputCopy` and `PricingCard` fall back through `--input-button-primary-*` for their own copy/CTA
78
+ buttons (see their own `CONSUMER-STYLING.md`) rather than duplicating this chain.
79
+
80
+ ---
81
+
82
+ ## Global theming — app-level CSS file
83
+
84
+ Set tokens on `:where(html)` in the consuming app's own theming file (e.g.
85
+ `assets/styles/setup/03.theming/_button.css`). These apply to every `InputButtonCore` instance:
86
+
87
+ ```css
88
+ :where(html) {
89
+ --input-button-primary-surface: var(--rose-09);
90
+ --input-button-primary-text: var(--rose-00);
91
+ /* Hover/border/ring all derive from --input-button-primary-surface automatically via
92
+ color-mix() — no need to also set -hover/-border/-ring unless you want a different
93
+ relationship than "same colour, darker". */
94
+ }
95
+ ```
96
+
97
+ Only declare the tokens you want to change — everything else keeps inheriting the shared
98
+ `--theme-*` slots (or, for hover, keeps darkening whatever surface you did set).
99
+
100
+ ---
101
+
102
+ ## Per-instance overrides
103
+
104
+ ```vue
105
+ <InputButtonCore variant="primary" button-text="Book now" style="--input-button-primary-surface: var(--gold-06);" />
106
+ ```
107
+
108
+ To make hover lighten instead of darken for one instance, override the hover token directly rather
109
+ than fighting the `color-mix()` default:
110
+
111
+ ```vue
112
+ <InputButtonCore
113
+ variant="primary"
114
+ button-text="Book now"
115
+ style="--input-button-primary-surface-hover: var(--gold-04);"
116
+ />
117
+ ```
@@ -106,6 +106,7 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
106
106
  justify-content: center;
107
107
  align-items: center;
108
108
  box-sizing: content-box;
109
+ min-height: var(--button-min-height);
109
110
  border-radius: var(--button-border-radius);
110
111
  font-family: var(--font-family);
111
112
  padding-inline: var(--button-padding-inline);
@@ -121,60 +122,105 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
121
122
 
122
123
  /*
123
124
  * Theming
125
+ * Every colour is a public --input-button-{variant}-{property} token, inline-fallback to
126
+ * the shared theme slot (see theming-component-token-pattern.md for why: overriding one
127
+ * variant here never touches the --theme-* slots other components read).
128
+ *
129
+ * Flat by default: border defaults to the variant's own surface colour (so the resting state
130
+ * shows no visible edge) and outline defaults to fully transparent — outline is reserved for
131
+ * the focus-visible ring, not used to reinforce the flat look. Hover darkens the surface via
132
+ * color-mix (always darker, regardless of which direction the underlying theme ramp runs in
133
+ * light vs dark mode — no more "hover looks lighter" surprises) and carries the border along
134
+ * with it, still flat; outline stays transparent through hover too.
135
+ * focus-visible is deliberately its own block, not grouped with :hover — it's the one state
136
+ * that gets a real, separately-themeable a11y indicator, and its outline-width/-offset (set
137
+ * below, in Shared States) give it a visible ring regardless of the border/outline being
138
+ * invisible everywhere else.
139
+ * --_surface is a private local (not a public token) — it exists purely so -hover/-border/-ring
140
+ * can color-mix() off the SAME resolved value a consumer's --input-button-{variant}-surface
141
+ * override produces, without every derived token repeating that fallback chain itself.
124
142
  **/
125
- background-color: var(--theme-surface);
126
- color: var(--theme-on-surface);
127
- border: var(--button-border-width) solid var(--theme-border);
128
- outline: var(--button-outline-width) solid var(--theme-ring);
143
+ &.primary {
144
+ --_surface: var(--input-button-primary-surface, var(--theme-surface));
145
+ --_surface-hover: var(--input-button-primary-surface-hover, color-mix(in oklab, var(--_surface) 85%, black));
129
146
 
130
- &:hover,
131
- &:focus-visible {
132
- background-color: var(--theme-surface-hover);
133
- /* --theme-on-surface, not --theme-text: --theme-surface-hover's light-mode branch is a
134
- dark step (by design — this button stays a bold colour even in light mode), and
135
- --theme-text's light-mode branch is dark too (it's meant for text on a light surface
136
- elsewhere, e.g. AlertContentInner). Pairing them made hover text invisible in light
137
- mode. --theme-on-surface is a fixed light value regardless of scheme, matching how
138
- AlertContentInner's own dismiss-button hover and .secondary:hover below already do it. */
139
- color: var(--theme-on-surface);
140
- border-color: var(--theme-border-focus);
141
- outline-color: var(--theme-ring);
147
+ background-color: var(--_surface);
148
+ color: var(--input-button-primary-text, var(--theme-on-surface));
149
+ border: var(--button-border-width) solid var(--input-button-primary-border, var(--_surface));
150
+ outline: var(--button-outline-width) solid var(--input-button-primary-ring, transparent);
151
+
152
+ &:hover {
153
+ background-color: var(--_surface-hover);
154
+ border-color: var(--input-button-primary-border-hover, var(--_surface-hover));
155
+ outline-color: var(--input-button-primary-ring-hover, transparent);
156
+ }
157
+
158
+ &:focus-visible {
159
+ border-color: var(--input-button-primary-border-focus, var(--theme-border-focus));
160
+ outline-color: var(--input-button-primary-ring-focus, var(--theme-ring));
161
+ }
142
162
  }
143
163
 
144
164
  &.is-pending {
145
- background-color: color-mix(in oklab, var(--theme-surface) 50%, transparent);
165
+ /* Always the primary surface regardless of variant — pre-existing behaviour, not
166
+ something this migration changes. */
167
+ background-color: color-mix(
168
+ in oklab,
169
+ var(--input-button-primary-surface, var(--theme-surface)) 50%,
170
+ transparent
171
+ );
146
172
  }
147
173
 
148
174
  &.secondary {
149
- background-color: var(--theme-surface-inverted);
150
- border: var(--button-border-width) solid var(--theme-border);
151
- color: var(--theme-text-inverted);
152
- outline: var(--button-outline-width) solid var(--theme-ring);
175
+ --_surface: var(--input-button-secondary-surface, var(--theme-surface-inverted));
176
+ --_surface-hover: var(
177
+ --input-button-secondary-surface-hover,
178
+ color-mix(in oklab, var(--_surface) 85%, black)
179
+ );
180
+
181
+ background-color: var(--_surface);
182
+ color: var(--input-button-secondary-text, var(--theme-text-inverted));
183
+ border: var(--button-border-width) solid var(--input-button-secondary-border, var(--_surface));
184
+ outline: var(--button-outline-width) solid var(--input-button-secondary-ring, transparent);
185
+
186
+ &:hover {
187
+ background-color: var(--_surface-hover);
188
+ border-color: var(--input-button-secondary-border-hover, var(--_surface-hover));
189
+ outline-color: var(--input-button-secondary-ring-hover, transparent);
190
+ }
153
191
 
154
- &:hover,
155
192
  &:focus-visible {
156
- background-color: var(--theme-surface);
157
- color: var(--theme-on-surface);
158
- border-color: var(--theme-border-focus);
159
- outline-color: var(--theme-surface);
193
+ border-color: var(--input-button-secondary-border-focus, var(--theme-border-focus));
194
+ outline-color: var(--input-button-secondary-ring-focus, var(--theme-ring));
160
195
  }
161
196
  }
162
197
 
163
198
  &.tertiary {
164
- background-color: light-dark(var(--slate-01), transparent);
165
- border: var(--button-border-width) solid var(--theme-border);
166
- color: var(--theme-text);
199
+ /* --_surface can resolve to literal transparent (dark mode default) — color-mix() still
200
+ darkens correctly here: mixing transparent with black lowers the alpha channel rather
201
+ than the lightness, so it reads as a faint dark tint over whatever's behind the button
202
+ instead of a flat fill. Same formula, no special-casing needed. */
203
+ --_surface: var(--input-button-tertiary-surface, light-dark(var(--slate-01), transparent));
204
+ --_surface-hover: var(
205
+ --input-button-tertiary-surface-hover,
206
+ color-mix(in oklab, var(--_surface) 85%, black)
207
+ );
208
+
209
+ background-color: var(--_surface);
210
+ color: var(--input-button-tertiary-text, var(--theme-text));
211
+ border: var(--button-border-width) solid var(--input-button-tertiary-border, var(--_surface));
212
+ outline: var(--button-outline-width) solid var(--input-button-tertiary-ring, transparent);
167
213
  text-decoration: underline;
168
- outline: var(--button-outline-width) solid var(--theme-border-focus);
169
214
 
170
- &:hover,
215
+ &:hover {
216
+ background-color: var(--_surface-hover);
217
+ border-color: var(--input-button-tertiary-border-hover, var(--_surface-hover));
218
+ outline-color: var(--input-button-tertiary-ring-hover, transparent);
219
+ }
220
+
171
221
  &:focus-visible {
172
- background-color: var(--theme-surface-hover);
173
- /* Same fix as the base :hover above — background goes dark in light mode, so text
174
- needs to switch off --theme-text (dark in light mode) to stay visible. */
175
- color: var(--theme-on-surface);
176
- border-color: var(--theme-border-focus);
177
- outline-color: var(--theme-border-focus);
222
+ border-color: var(--input-button-tertiary-border-focus, var(--theme-border-focus));
223
+ outline-color: var(--input-button-tertiary-ring-focus, var(--theme-ring));
178
224
  }
179
225
  }
180
226
 
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
2
  import { ref } from "vue";
3
3
  import StorybookComponent from "../InputButtonCore.vue";
4
+ import InputTextCore from "../../input-text/InputTextCore.vue";
4
5
 
5
6
  interface InputButtonCoreStoryArgs {
6
7
  type: "submit" | "button" | "reset";
@@ -240,7 +241,17 @@ const Template: StoryFn<InputButtonCoreStoryArgs> = (args) => ({
240
241
  },
241
242
  template: `
242
243
  <div style="display: flex; flex-direction: column; gap: 1rem; align-items: flex-start;margin: 36px;">
244
+ <!-- :key forces a remount when any slot-visibility arg changes. Verified the args
245
+ themselves ARE reactive here (a plain {{ args.useLeftSlot }} interpolation updates
246
+ live on toggle) — the bug is narrower: Vue's compiled-at-runtime string template
247
+ doesn't reliably re-evaluate a <template v-if="..."> conditionally attached to a named
248
+ slot when only the v-if's condition changes, on this component/Storybook combination.
249
+ Toggling a control changed the fallthrough attribute (confirmed via useleftslot="true"
250
+ in the rendered DOM) but never actually added/removed the slot content until a hard
251
+ refresh. Remounting sidesteps whatever the underlying slot-stability quirk is, rather
252
+ than chasing it further. -->
243
253
  <StorybookComponent
254
+ :key="[args.useLeftSlot, args.useLeftIcon, args.useRightSlot, args.useRightIcon, args.useIconOnlySlot, args.useIconOnly].join(',')"
244
255
  v-bind="args"
245
256
  @click="handleClick"
246
257
  >
@@ -302,3 +313,27 @@ NuxtIconOnlyComponent.args = {
302
313
  useIconOnly: true,
303
314
  iconOnlyName: "mdi:chevron-right-circle-outline",
304
315
  };
316
+
317
+ // Composition check, not a props demo: confirms InputButtonCore's --button-min-height (defaults
318
+ // to var(--input-min-height)) actually keeps it height-aligned with an inline text input, the
319
+ // most common "usual suspect" pairing (e.g. newsletter signup, search bar). Textareas are the
320
+ // deliberate exception to this alignment and aren't part of this story.
321
+ const InlineTemplate: StoryFn = () => ({
322
+ components: { StorybookComponent, InputTextCore },
323
+ template: `
324
+ <div style="margin: 36px; max-width: 480px;">
325
+ <div style="display: flex; align-items: flex-start; gap: 0.8rem;">
326
+ <InputTextCore
327
+ id="inline-email"
328
+ name="email"
329
+ type="email"
330
+ placeholder="you@example.com"
331
+ style="flex: 1;"
332
+ />
333
+ <StorybookComponent type="submit" variant="primary" button-text="Subscribe" />
334
+ </div>
335
+ </div>
336
+ `,
337
+ });
338
+
339
+ export const InlineWithTextInput = InlineTemplate.bind({});