srcdev-nuxt-components 9.3.12 → 9.4.0

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 (62) hide show
  1. package/.claude/skills/components/cookie-consent-banner.md +88 -0
  2. package/.claude/skills/composable-cookie-consent.md +78 -0
  3. package/.claude/skills/composable-google-analytics.md +87 -0
  4. package/.claude/skills/index.md +5 -1
  5. package/.claude/skills/theming-component-token-pattern.md +126 -0
  6. package/app/app.config.ts +10 -1
  7. package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +10 -1
  8. package/app/components/01.atoms/cookie-consent-banner/CookieConsentBanner.vue +148 -0
  9. package/app/components/01.atoms/cookie-consent-banner/stories/CookieConsentBanner.stories.ts +93 -0
  10. package/app/components/01.atoms/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +118 -0
  11. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +3 -3
  12. package/app/components/02.molecules/input-copy/InputCopy.vue +9 -3
  13. package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +3 -3
  14. package/app/components/02.molecules/pricing-card/PricingCard.vue +6 -3
  15. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +117 -0
  16. package/app/components/05.forms/input-button/InputButtonCore.vue +83 -37
  17. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-secondary-chromium-darwin.png +0 -0
  18. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-tertiary-chromium-darwin.png +0 -0
  19. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-primary-pending-chromium-darwin.png +0 -0
  20. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-secondary-pill-chromium-darwin.png +0 -0
  21. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-tertiary-readonly-chromium-darwin.png +0 -0
  22. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-chromium-darwin.png +0 -0
  23. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-default-chromium-darwin.png +0 -0
  24. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-error-chromium-darwin.png +0 -0
  25. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-success-chromium-darwin.png +0 -0
  26. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-warning-chromium-darwin.png +0 -0
  27. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-primary-chromium-darwin.png +0 -0
  28. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-secondary-chromium-darwin.png +0 -0
  29. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-tertiary-chromium-darwin.png +0 -0
  30. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/nuxt-icon-only-component-chromium-darwin.png +0 -0
  31. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pending-chromium-darwin.png +0 -0
  32. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pill-chromium-darwin.png +0 -0
  33. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-readonly-chromium-darwin.png +0 -0
  34. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/with-both-nuxt-icon-components-chromium-darwin.png +0 -0
  35. package/app/components/05.forms/input-button/stories/InputButtonCore.stories.ts +35 -0
  36. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +68 -0
  37. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +19 -9
  38. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioCore.vue +8 -5
  39. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +93 -0
  40. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioCore.stories.ts +75 -0
  41. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +41 -0
  42. package/app/components/05.forms/input-number/InputNumberCore.vue +12 -6
  43. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +35 -29
  44. package/app/components/05.forms/input-select/InputSelectCore.vue +32 -20
  45. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +69 -0
  46. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +38 -48
  47. package/app/components/05.forms/input-text/InputTextCore.vue +35 -18
  48. package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +184 -0
  49. package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +125 -0
  50. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +49 -0
  51. package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +23 -16
  52. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +72 -0
  53. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +42 -20
  54. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitchCore.stories.ts +63 -0
  55. package/app/components/display-theme-switch/DisplayThemeSwitch.vue +13 -6
  56. package/app/composables/tests/useCookieConsent.spec.ts +87 -0
  57. package/app/composables/useCookieConsent.ts +54 -0
  58. package/app/composables/useGoogleAnalytics.ts +26 -0
  59. package/app/types/components/cookie-consent-banner.d.ts +8 -0
  60. package/app/types/components/index.ts +1 -0
  61. package/nuxt.config.ts +6 -0
  62. package/package.json +2 -1
@@ -0,0 +1,88 @@
1
+ # CookieConsentBanner
2
+
3
+ ## Overview
4
+
5
+ Fixed, non-modal banner shown while cookie consent is undecided (`useCookieConsent().status === 'unset'`). Accept/Reject buttons call `acceptAll()`/`rejectAll()` on [[composable-cookie-consent]] directly — no `v-model`, no emits to wire up. Pair with [[composable-google-analytics]] (or any other consent-gated script) which reads the same consent state.
6
+
7
+ **Location**: `app/components/01.atoms/cookie-consent-banner/CookieConsentBanner.vue`
8
+
9
+ **Types**: `~/types/components` — `CookieConsentBannerProps`, `CookieConsentStatus`
10
+
11
+ ---
12
+
13
+ ## Props
14
+
15
+ | Prop | Type | Default | Notes |
16
+ |---|---|---|---|
17
+ | `theme` | `SemanticTheme` | `"info"` | Colours the banner's accent border and accept button via `--theme-accent`. |
18
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes on the root element. |
19
+
20
+ ### app.config defaults
21
+
22
+ ```ts
23
+ // Consumer's app.config.ts
24
+ export default defineAppConfig({
25
+ srcdev: {
26
+ cookieConsentBanner: {
27
+ theme: "info",
28
+ },
29
+ },
30
+ })
31
+ ```
32
+
33
+ Resolution chain: **prop → app.config → hardcoded fallback**.
34
+
35
+ ## Slots
36
+
37
+ | Slot | Purpose | Default |
38
+ |---|---|---|
39
+ | `message` | The consent message body | "This site uses cookies to understand how it's used. You can accept or reject them." |
40
+ | `acceptLabel` | Accept button text | "Accept" |
41
+ | `rejectLabel` | Reject button text | "Reject" |
42
+
43
+ Per [[feedback_i18n_required]] in consuming apps, always fill these slots with `t()`-sourced copy rather than relying on the English defaults.
44
+
45
+ ## Basic usage
46
+
47
+ Register once, in the app's default layout, alongside `DisplayToastProvider` and a call to `useGoogleAnalytics()`:
48
+
49
+ ```vue
50
+ <!-- layouts/default.vue -->
51
+ <script setup lang="ts">
52
+ const { t } = useI18n();
53
+ useGoogleAnalytics();
54
+ </script>
55
+
56
+ <template>
57
+ <div class="page-layout">
58
+ <slot />
59
+ <DisplayToastProvider position="top" alignment="right" :max-visible="3" />
60
+ <CookieConsentBanner>
61
+ <template #message>{{ t("global.cookieConsent.message") }}</template>
62
+ <template #acceptLabel>{{ t("global.cookieConsent.accept") }}</template>
63
+ <template #rejectLabel>{{ t("global.cookieConsent.reject") }}</template>
64
+ </CookieConsentBanner>
65
+ </div>
66
+ </template>
67
+ ```
68
+
69
+ ## CSS / styling
70
+
71
+ Public tokens (all on `.cookie-consent-banner`, `var(--cookie-consent-banner-*, fallback)` pattern):
72
+
73
+ | Token | Default |
74
+ |---|---|
75
+ | `--cookie-consent-banner-z-index` | `999999` |
76
+ | `--cookie-consent-banner-gutter` | `1.6rem` |
77
+ | `--cookie-consent-banner-max-width` | `64rem` |
78
+ | `--cookie-consent-banner-border-radius` | `0.8rem` |
79
+ | `--cookie-consent-banner-border` | `0.1rem solid light-dark(var(--slate-10), var(--slate-02))` |
80
+ | `--cookie-consent-banner-background` | `light-dark(var(--slate-00), var(--slate-10))` |
81
+ | `--cookie-consent-banner-transition-duration` | `200ms` |
82
+
83
+ ## Notes
84
+
85
+ - **Teleported to `<body>`** — like `DisplayToastProvider`, query it in tests via `document.querySelector(".cookie-consent-banner")`, not `wrapper.find(...)`.
86
+ - **No focus trap / backdrop** — this is a dismiss-by-decision banner, not a modal. It collapses via a `grid-template-rows` transition (same mechanic as `DisplayPrompt`) once `status` leaves `"unset"`, rather than unmounting.
87
+ - **Only ever one instance** — `useCookieConsent()`'s underlying state is a module-scope singleton, so mounting the banner twice in one app just duplicates the UI, it doesn't create separate consent state.
88
+ - To let a visitor change their mind later (e.g. from a cookie-policy page), call `useCookieConsent().rejectAll()` or clear the `cookie-consent` cookie — the banner reappears since `status` returns to `"unset"` only once the cookie is gone; `rejectAll()` itself sets it to `"denied"`, which keeps the banner hidden but stops GA. Expose a dedicated "reset my choice" affordance if you want the banner itself to resurface.
@@ -0,0 +1,78 @@
1
+ # useCookieConsent Composable
2
+
3
+ ## Overview
4
+
5
+ `useCookieConsent` tracks the visitor's cookie-consent decision (`unset` / `granted` / `denied`), persists it in a `cookie-consent` cookie, and wraps `@nuxt/scripts`' `useScriptTriggerConsent()` gate so any consent-dependent script (Google Analytics via [[composable-google-analytics]], or anything else added later) can be wired to it.
6
+
7
+ **This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useCookieConsent.ts`). Consuming apps get it via Nuxt's layer auto-import — **do not create a local copy** in the consuming app.
8
+
9
+ ## Prerequisites
10
+
11
+ - `@nuxt/scripts` — the layer itself declares this as a dependency and registers it in its own `modules` array, so it's present in any app that extends this layer. No action needed in the consuming app, but be aware `useScriptTriggerConsent`/`useScriptGoogleAnalytics` are auto-imported from that module, not this layer.
12
+
13
+ ## Setup in the consuming app
14
+
15
+ ### 1. No import needed
16
+
17
+ `useCookieConsent` is auto-imported by Nuxt from the layer. Use it directly in `<script setup>` or any composable without an explicit import.
18
+
19
+ ### 2. Render the banner
20
+
21
+ Pair it with [[cookie-consent-banner]] (`CookieConsentBanner.vue`), registered once in the app's default layout — see that component's skill doc for placement and copy slots.
22
+
23
+ ## Composable reference
24
+
25
+ Source lives at `app/composables/useCookieConsent.ts` in the layer. Shown here for reference only — do not recreate it in the consuming app.
26
+
27
+ ```ts
28
+ const consentTrigger = useScriptTriggerConsent(); // module-scope singleton
29
+
30
+ export function useCookieConsent() {
31
+ const stored = useCookie<"granted" | "denied" | null>("cookie-consent", {
32
+ maxAge: 60 * 60 * 24 * 365,
33
+ sameSite: "lax",
34
+ default: () => null,
35
+ });
36
+
37
+ if (stored.value === "granted" && !consentTrigger.consented.value) {
38
+ consentTrigger.accept();
39
+ }
40
+
41
+ const status = computed(() => stored.value ?? "unset");
42
+
43
+ const acceptAll = () => {
44
+ stored.value = "granted";
45
+ consentTrigger.accept();
46
+ };
47
+
48
+ const rejectAll = () => {
49
+ stored.value = "denied";
50
+ consentTrigger.revoke();
51
+ };
52
+
53
+ return { status, acceptAll, rejectAll, trigger: consentTrigger };
54
+ }
55
+ ```
56
+
57
+ ### Key rules
58
+
59
+ - **`useScriptTriggerConsent()` is called once at module scope**, mirroring `@nuxt/scripts`' own documented pattern — it's a single shared gate for the app's lifetime, not a fresh instance per call-site. `useCookie()` is read fresh inside the function body on every call instead, which stays SSR-request-safe (Nuxt dedupes `useCookie()` by key within a single request).
60
+ - **`status` is the public read API.** `trigger` is exposed only so `useGoogleAnalytics` (or another consent-gated script composable) can pass it straight into `scriptOptions.trigger` — don't read/mutate `trigger` directly from app code, use `status`/`acceptAll`/`rejectAll`.
61
+ - A prior "granted" cookie is replayed into the trigger on init, since the in-memory trigger resets on every full page load but the cookie doesn't.
62
+
63
+ ## Usage
64
+
65
+ ```ts
66
+ const { status, acceptAll, rejectAll } = useCookieConsent();
67
+
68
+ if (status.value === "unset") {
69
+ // show the banner
70
+ }
71
+ ```
72
+
73
+ To let a user change their mind later (e.g. a "cookie preferences" link in the footer or a cookie-policy page), call `rejectAll()` or clear the `cookie-consent` cookie to bring the banner back.
74
+
75
+ ## Notes
76
+
77
+ - Cookie name `cookie-consent` is fixed by the layer, not configurable per app.
78
+ - This composable only tracks the yes/no decision — it does not itself load any script. See [[composable-google-analytics]] for the GA4 integration that consumes `trigger`.
@@ -0,0 +1,87 @@
1
+ # useGoogleAnalytics Composable
2
+
3
+ ## Overview
4
+
5
+ `useGoogleAnalytics` loads Google Analytics 4 (`gtag.js`) via `@nuxt/scripts`' `useScriptGoogleAnalytics()`, gated behind [[composable-cookie-consent]]'s consent state and Google Consent Mode v2's `defaultConsent`. The script does not fetch, and no analytics cookie is set, until the visitor accepts via [[cookie-consent-banner]] (or your own call to `useCookieConsent().acceptAll()`).
6
+
7
+ **This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useGoogleAnalytics.ts`). Consuming apps get it via Nuxt's layer auto-import — **do not create a local copy** in the consuming app.
8
+
9
+ ## Prerequisites
10
+
11
+ - `NUXT_PUBLIC_GOOGLE_ANALYTICS_ID` env var set to your GA4 measurement ID (`G-XXXXXXXXXX`).
12
+ - `@nuxt/scripts` — already a layer dependency, registered in the layer's own `modules` array. Nothing to add in the consuming app.
13
+ - The app's CSP (if using `nuxt-security`) must allow `https://www.googletagmanager.com` in `script-src`/`connect-src` and `https://www.google-analytics.com` in `connect-src`, or the script will be blocked once consent is granted.
14
+
15
+ ## Setup in the consuming app
16
+
17
+ ### 1. Runtime config
18
+
19
+ ```ts
20
+ // nuxt.config.ts
21
+ runtimeConfig: {
22
+ public: {
23
+ googleAnalytics: {
24
+ id: "", // NUXT_PUBLIC_GOOGLE_ANALYTICS_ID
25
+ },
26
+ },
27
+ },
28
+ ```
29
+
30
+ ### 2. Call it once
31
+
32
+ Call `useGoogleAnalytics()` once, near the app root — e.g. in the default layout's `<script setup>`, alongside where [[cookie-consent-banner]] is registered:
33
+
34
+ ```vue
35
+ <script setup lang="ts">
36
+ useGoogleAnalytics();
37
+ </script>
38
+
39
+ <template>
40
+ <div class="page-layout">
41
+ <slot />
42
+ <CookieConsentBanner />
43
+ </div>
44
+ </template>
45
+ ```
46
+
47
+ It no-ops (with a console warning) if `googleAnalytics.id` is unset — safe to call unconditionally in every environment, including local dev without a real measurement ID.
48
+
49
+ ## Composable reference
50
+
51
+ Source lives at `app/composables/useGoogleAnalytics.ts` in the layer. Shown here for reference only — do not recreate it in the consuming app.
52
+
53
+ ```ts
54
+ export const useGoogleAnalytics = () => {
55
+ const config = useRuntimeConfig();
56
+ const id = config.public.googleAnalytics?.id;
57
+
58
+ if (!id) {
59
+ console.warn("[useGoogleAnalytics] public.googleAnalytics.id is not configured");
60
+ return;
61
+ }
62
+
63
+ const { trigger } = useCookieConsent();
64
+
65
+ useScriptGoogleAnalytics({
66
+ id,
67
+ scriptOptions: { trigger },
68
+ defaultConsent: {
69
+ ad_storage: "denied",
70
+ ad_user_data: "denied",
71
+ ad_personalization: "denied",
72
+ analytics_storage: "denied",
73
+ },
74
+ });
75
+ };
76
+ ```
77
+
78
+ ### Key rules
79
+
80
+ - **`useRuntimeConfig()` inside the function body**, not module scope — same reasoning as `useWhatsApp` (see [[composable-whatsapp]]).
81
+ - **`defaultConsent` is all `"denied"`** — this is what makes the script Consent Mode v2 compliant: gtag.js can load (if triggered) but won't set cookies or send identifiable pings until `useCookieConsent().acceptAll()` calls `trigger.accept()`, which flips consent to granted via `@nuxt/scripts`' own consent-update wiring.
82
+ - Does not return anything — it's a side-effecting setup call, not a value composable. Read GA-related state (if ever needed) via `useCookieConsent()` instead.
83
+
84
+ ## Notes
85
+
86
+ - Only loads GA4 (`gtag.js`) — no other Google tags (Ads, Tag Manager container, etc.). Extend `defaultConsent`/add a second `useScript*` call if those are needed later.
87
+ - The measurement ID in `public` config is visible in the client bundle — expected, GA4 IDs are not secret.
@@ -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
@@ -58,6 +59,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
58
59
  ├── composable-dialog-controls.md — useDialogControls: single-call setup with config object, openDialog/closeDialog API, confirm/cancel callbacks
59
60
  ├── composable-anchor-scroll.md — useAnchorScroll: smooth anchor scrolling with reduced-motion support, dynamic offset, and TabNavigation integration
60
61
  ├── composable-tooltips-guide.md — useTooltipsGuide: sequential popover guide with auto-start, dismiss-to-advance, manual controls
62
+ ├── composable-cookie-consent.md — useCookieConsent: unset/granted/denied state, cookie persistence, wraps @nuxt/scripts' useScriptTriggerConsent
63
+ ├── composable-google-analytics.md — useGoogleAnalytics: GA4 via @nuxt/scripts' useScriptGoogleAnalytics, Consent Mode v2 defaultConsent, gated on useCookieConsent
61
64
  └── components/
62
65
  ├── alert-content-inner.md — AlertContentInner: shared icon/body/dismiss molecule under AlertContent + AlertMaskedContent; app.config icon map (alertContent.icons + dismissIcon) covers all consumers
63
66
  ├── accordian-core.md — AccordianCore indexed dynamic slots (accordian-{n}-summary/icon/content), exclusive-open grouping
@@ -105,7 +108,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
105
108
  ├── expanding-panel-classic.md — ExpandingPanelClassic: grid-template-rows animation (no Baseline-2025 dependency), same API as ExpandingPanel, cross-browser animation parity trade-off
106
109
  ├── site-header.md — SiteHeader: PageRow + SkipLinks + ResponsiveHeader composition, #branding/#secondaryNavigation slots, dual styleClassPassthrough hooks
107
110
  ├── responsive-header.md — ResponsiveHeader: overflow-collapsing adaptive nav, measurement-pipeline gotchas (unsized icons, vw font-size drift), full CSS token API
108
- └── navigation-items.md — NavigationItems: internal overflow-panel renderer for ResponsiveHeader, complement-visibility logic, not used standalone
111
+ ├── navigation-items.md — NavigationItems: internal overflow-panel renderer for ResponsiveHeader, complement-visibility logic, not used standalone
112
+ └── cookie-consent-banner.md — CookieConsentBanner: fixed non-modal Accept/Reject banner driven by useCookieConsent, message/acceptLabel/rejectLabel slots, CSS token API
109
113
  ```
110
114
 
111
115
  ## Skill file template
@@ -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.
package/app/app.config.ts CHANGED
@@ -1,4 +1,10 @@
1
- import type { SemanticTheme, DisplayPromptTheme, DisplayToastTheme, DisplayToastPosition, DisplayToastAlignment } from "./types/components";
1
+ import type {
2
+ SemanticTheme,
3
+ DisplayPromptTheme,
4
+ DisplayToastTheme,
5
+ DisplayToastPosition,
6
+ DisplayToastAlignment,
7
+ } from "./types/components";
2
8
 
3
9
  export default defineAppConfig({
4
10
  srcdev: {
@@ -40,5 +46,8 @@ export default defineAppConfig({
40
46
  theme: undefined as SemanticTheme | undefined,
41
47
  closeIcon: "bitcoin-icons:cross-filled",
42
48
  },
49
+ cookieConsentBanner: {
50
+ theme: "info" as SemanticTheme,
51
+ },
43
52
  },
44
53
  });
@@ -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
  }
@@ -0,0 +1,148 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ class="cookie-consent-banner"
5
+ :class="[{ closed: status !== 'unset' }, elementClasses]"
6
+ :data-theme="resolved.theme"
7
+ data-test-id="cookie-consent-banner"
8
+ >
9
+ <div class="cookie-consent-banner-inner" role="region" :aria-label="ariaLabel">
10
+ <div class="cookie-consent-banner-message">
11
+ <slot name="message">This site uses cookies to understand how it's used. You can accept or reject them.</slot>
12
+ </div>
13
+ <div class="cookie-consent-banner-actions">
14
+ <button
15
+ type="button"
16
+ class="cookie-consent-banner-reject"
17
+ data-test-id="cookie-consent-banner-reject"
18
+ @click="rejectAll()"
19
+ >
20
+ <slot name="rejectLabel">Reject</slot>
21
+ </button>
22
+ <button
23
+ type="button"
24
+ class="cookie-consent-banner-accept"
25
+ data-test-id="cookie-consent-banner-accept"
26
+ @click="acceptAll()"
27
+ >
28
+ <slot name="acceptLabel">Accept</slot>
29
+ </button>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </Teleport>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ import type { CookieConsentBannerProps } from "../../../types/components";
38
+
39
+ const props = withDefaults(defineProps<CookieConsentBannerProps>(), {
40
+ theme: undefined,
41
+ styleClassPassthrough: () => [],
42
+ });
43
+
44
+ const appConfig = useAppConfig();
45
+
46
+ const resolved = computed(() => {
47
+ const config = appConfig.srcdev?.cookieConsentBanner;
48
+ return {
49
+ theme: props.theme ?? config?.theme ?? "info",
50
+ } as const;
51
+ });
52
+
53
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
54
+ const { status, acceptAll, rejectAll } = useCookieConsent();
55
+ const ariaLabel = "Cookie consent";
56
+ </script>
57
+
58
+ <style lang="css">
59
+ @layer components {
60
+ .cookie-consent-banner {
61
+ /* Matches DisplayToastProvider/DisplayDialog's z-index convention so
62
+ this clears ordinary page chrome (and a consumer's sticky header) but
63
+ still sits below an active modal dialog if one somehow overlaps. */
64
+ --_z-index: var(--cookie-consent-banner-z-index, 999999);
65
+ --_gutter: var(--cookie-consent-banner-gutter, 1.6rem);
66
+ --_max-width: var(--cookie-consent-banner-max-width, 64rem);
67
+ --_border-radius: var(--cookie-consent-banner-border-radius, 0.8rem);
68
+ --_border: var(--cookie-consent-banner-border, 0.1rem solid light-dark(var(--slate-10), var(--slate-02)));
69
+ --_background: var(--cookie-consent-banner-background, light-dark(var(--slate-00), var(--slate-10)));
70
+ --_transition-duration: var(--cookie-consent-banner-transition-duration, 200ms);
71
+
72
+ position: fixed;
73
+ inset-inline: var(--_gutter);
74
+ inset-block-end: var(--_gutter);
75
+ z-index: var(--_z-index);
76
+ margin-inline: auto;
77
+ max-width: var(--_max-width);
78
+
79
+ display: grid;
80
+ grid-template-rows: 1fr;
81
+ opacity: 1;
82
+ transition: all var(--_transition-duration) ease-in-out;
83
+
84
+ &.closed {
85
+ grid-template-rows: 0fr;
86
+ opacity: 0;
87
+ pointer-events: none;
88
+ }
89
+
90
+ .cookie-consent-banner-inner {
91
+ overflow: hidden;
92
+ display: flex;
93
+ flex-wrap: wrap;
94
+ align-items: center;
95
+ gap: 1.2rem;
96
+ padding: 1.6rem;
97
+ border-radius: var(--_border-radius);
98
+ border: var(--_border);
99
+ background-color: var(--_background);
100
+
101
+ &[aria-label] {
102
+ border-block-start: 0.2rem solid var(--theme-accent);
103
+ }
104
+ }
105
+
106
+ .cookie-consent-banner-message {
107
+ flex: 1 1 24rem;
108
+ }
109
+
110
+ .cookie-consent-banner-actions {
111
+ display: flex;
112
+ gap: 0.8rem;
113
+ margin-inline-start: auto;
114
+ }
115
+
116
+ .cookie-consent-banner-reject,
117
+ .cookie-consent-banner-accept {
118
+ padding: 0.8rem 1.6rem;
119
+ border-radius: 0.4rem;
120
+ border: 0.1rem solid transparent;
121
+ cursor: pointer;
122
+ transition:
123
+ border-color var(--_transition-duration),
124
+ background-color var(--_transition-duration);
125
+ }
126
+
127
+ .cookie-consent-banner-reject {
128
+ background-color: transparent;
129
+ border: 0.1rem solid light-dark(var(--slate-08), var(--slate-04));
130
+
131
+ &:hover,
132
+ &:focus-visible {
133
+ border-color: var(--theme-accent);
134
+ }
135
+ }
136
+
137
+ .cookie-consent-banner-accept {
138
+ background-color: var(--theme-accent);
139
+ color: light-dark(var(--slate-00), var(--slate-12));
140
+
141
+ &:hover,
142
+ &:focus-visible {
143
+ opacity: 0.9;
144
+ }
145
+ }
146
+ }
147
+ }
148
+ </style>