srcdev-nuxt-components 9.5.3 → 9.5.5

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/colour-scheme-disable.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +214 -0
  3. package/.claude/skills/component-inline-action-button.md +2 -2
  4. package/.claude/skills/components/carousel-flip.md +3 -3
  5. package/.claude/skills/components/clipped-panel.md +2 -2
  6. package/.claude/skills/components/dashboard-quad-grid.md +2 -2
  7. package/.claude/skills/components/dashboard-stats-grid.md +1 -1
  8. package/.claude/skills/components/deep-expanding-menu-classic.md +1 -1
  9. package/.claude/skills/components/deep-expanding-menu.md +1 -1
  10. package/.claude/skills/components/google-reviews.md +193 -0
  11. package/.claude/skills/components/input-number.md +4 -0
  12. package/.claude/skills/components/input-otp.md +120 -0
  13. package/.claude/skills/components/section-parallax.md +1 -1
  14. package/.claude/skills/components/select-menu.md +19 -5
  15. package/.claude/skills/components/triple-toggle-switch.md +7 -1
  16. package/.claude/skills/composable-colour-scheme.md +2 -0
  17. package/.claude/skills/index.md +5 -1
  18. package/.claude/skills/new-app-scaffold.md +1 -1
  19. package/.claude/skills/theming-colour-ramps.md +6 -33
  20. package/.claude/skills/theming-component-token-pattern.md +30 -0
  21. package/.claude/skills/theming-dark-mode.md +243 -0
  22. package/.claude/skills/theming-override-default.md +6 -7
  23. package/.claude/skills/theming-partial-override.md +11 -11
  24. package/.vscode/srcdev-component-google-reviews.code-snippets +78 -0
  25. package/.vscode/srcdev-component-input-otp.code-snippets +47 -0
  26. package/.vscode/srcdev-component-select-menu.code-snippets +13 -0
  27. package/README.md +81 -41
  28. package/app/assets/styles/setup/01.config/_head.css +0 -9
  29. package/app/assets/styles/setup/03.theming/_default.css +29 -29
  30. package/app/assets/styles/setup/03.theming/_error.css +3 -3
  31. package/app/assets/styles/setup/03.theming/_theme-slots.css +13 -16
  32. package/app/assets/styles/setup/a11y/_variables.css +2 -2
  33. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +7 -7
  34. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +19 -1
  35. package/app/components/02.molecules/select-menu/SelectMenu.vue +54 -3
  36. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +35 -0
  37. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +9 -0
  38. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +12 -12
  39. package/app/components/03.organisms/google-reviews/CONSUMER-STYLING.md +110 -0
  40. package/app/components/03.organisms/google-reviews/GoogleReviewCard.vue +161 -0
  41. package/app/components/03.organisms/google-reviews/GoogleReviews.vue +275 -0
  42. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +323 -0
  43. package/app/components/03.organisms/google-reviews/tests/GoogleReviewCard.spec.ts +104 -0
  44. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +232 -0
  45. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +9 -0
  46. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +43 -0
  47. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +1 -1
  48. package/app/components/05.forms/input-number/InputNumber.vue +2 -2
  49. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +59 -0
  50. package/app/components/05.forms/input-otp/InputOtp.vue +254 -0
  51. package/app/components/05.forms/input-otp/InputOtpField.vue +106 -0
  52. package/app/components/05.forms/input-otp/stories/InputOtp.stories.ts +100 -0
  53. package/app/components/05.forms/input-otp/stories/InputOtpField.stories.ts +163 -0
  54. package/app/components/05.forms/input-otp/tests/InputOtp.spec.ts +275 -0
  55. package/app/components/05.forms/input-otp/tests/InputOtpField.spec.ts +76 -0
  56. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +151 -33
  57. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +11 -3
  58. package/app/composables/useGoogleReviews.ts +4 -0
  59. package/app/types/components/google-reviews.d.ts +18 -0
  60. package/app/types/components/index.ts +1 -0
  61. package/nuxt.config.ts +9 -9
  62. package/package.json +9 -8
@@ -57,7 +57,7 @@ The component sets:
57
57
  - `background-position: var(--section-parallax-background-position, center)`
58
58
  - `background-size: var(--section-parallax-background-size, cover)`
59
59
  - `min-height: var(--section-parallax-min-height, 100svh)`
60
- - `background-color: var(--section-parallax-background-colour, light-dark(var(--slate-01), var(--slate-08)))` (visible if image fails to load)
60
+ - `background-color: var(--section-parallax-background-colour, var(--slate-01))` (visible if image fails to load)
61
61
 
62
62
  Inside `@media (hover: hover) and (pointer: fine) { @supports (background-attachment: fixed) { ... } }`:
63
63
 
@@ -17,7 +17,7 @@ open between picks.
17
17
 
18
18
  ## Components
19
19
 
20
- ### SelectMenu
20
+ ### SelectMenu props
21
21
 
22
22
  | Prop | Type | Default | Notes |
23
23
  |---|---|---|---|
@@ -29,9 +29,10 @@ open between picks.
29
29
  | `showChevron` | `boolean` | `true` | Show the trailing chevron in the trigger. |
30
30
  | `multiple` | `boolean` | `false` | Allow selecting more than one option. Each option gets a checkbox indicator, `v-model` becomes an array, and picking an option leaves the popover open so more can be toggled. Trigger icon is not shown when `true` (no single option to represent). |
31
31
  | `showSelectionInTrigger` | `boolean` | `false` | Multiple mode only. When `true`, the trigger text updates to a comma-separated list of the currently checked options instead of staying fixed on `placeholder`/`label`. No effect outside `multiple`. |
32
+ | `inputVariant` | `InputUiVariant` | `"normal"` | Trigger border style, same prop as `InputSelect`: `"normal"` (bordered box) or `"underlined"` (bottom border only, square corners), added as a class on the root. `"outlined"` has no CSS, same as the `05.forms` inputs. |
32
33
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes on the root `<div>`. |
33
34
 
34
- **v-model**
35
+ #### v-model
35
36
 
36
37
  `defineModel<string | number | (string | number)[] | undefined>({ default: undefined })` — the
37
38
  selected value(s). In single-select mode (default) it's the one selected option's `value`, or
@@ -44,7 +45,7 @@ is optional by design (see that rule's `defineModel` gap noted in the repo's led
44
45
  docs). For several independent single-select categories, place multiple `SelectMenu` instances
45
46
  side by side rather than using `multiple` on one instance — see the "Filter bar" section below.
46
47
 
47
- **Type import**
48
+ #### Type import
48
49
 
49
50
  ```ts
50
51
  import type { SelectMenuOption } from "~/types/components/select-menu";
@@ -170,8 +171,11 @@ Quick reference:
170
171
 
171
172
  ## Notes
172
173
 
173
- - **Popover API + CSS anchor positioning** — same mechanism as `ActionMenu`. Broadly supported
174
- (Chrome 114+, Firefox 125+, Safari 17+). No polyfill is included.
174
+ - **Popover API + CSS anchor positioning** — same mechanism as `ActionMenu`. The Popover API is
175
+ broadly supported (Chrome 114+, Firefox 125+, Safari 17+), but CSS anchor positioning is much
176
+ newer (Chromium 125+, Safari 26+), and without it the popover isn't placed under the trigger. No
177
+ polyfill or fallback is included, so check support before using this where older devices
178
+ matter; a native `<select>` (`InputSelect`) is the safer choice there.
175
179
  - **Left-aligned popover** (`left: anchor(left)`) — unlike `ActionMenu` which right-aligns. Matches
176
180
  native `<select>` dropdown behaviour. Flips above the trigger near the bottom of the viewport.
177
181
  - **Keyboard navigation** follows the WAI-ARIA listbox pattern: `ArrowDown`/`ArrowUp` move between
@@ -184,6 +188,16 @@ Quick reference:
184
188
  `[role="option"]` if one exists (in `multiple` mode, the first checked item), otherwise the first
185
189
  option (unlike `ActionMenu`, which always focuses the first item since it has no selection state).
186
190
  - **`aria-multiselectable="true"`** is set on the listbox only when `multiple` is `true`.
191
+ - **Selected rows are tinted** via `[aria-selected="true"]` (`--select-menu-item-surface-selected`,
192
+ default `--theme-surface-subtle`), in both single and multiple mode. Hover/focus is declared after
193
+ it at equal specificity, so the hover surface still shows on a selected row. Selected text colour,
194
+ font weight and border colour have their own `-selected` tokens falling back to the resting ones.
195
+ The row border width (`--select-menu-item-border-width`, default `0`) applies to every row with a
196
+ transparent resting colour, so a selected border never shifts layout. A hidden `::after` carrying
197
+ `data-label` in the selected weight reserves the label column's width, so a bolder selected
198
+ weight doesn't resize the popover. Hover/focus has `-text-color-hover` and `-border-hover`, which
199
+ fall back to the row's *current* state (selected or resting), not always to resting. There's no
200
+ hover font-weight token on purpose: the width reserve only covers one alternate weight.
187
201
  - **Chevron rotation is pure CSS** via `:has(.select-menu-popover:popover-open)` on the root — no
188
202
  JS state drives the visual. `isOpen` is still tracked internally, but only to set `aria-expanded`
189
203
  on the trigger.
@@ -28,7 +28,7 @@ for that for a theme switch; use `TripleToggleSwitch` directly for any other thr
28
28
  | `name` | `string` | `"triple-toggle-switch"` | Shared radio `name`. Give each instance on a page its own. |
29
29
  | `aria-label` | `string` | `""` | Accessible name for the `role="radiogroup"`. Omitted from the DOM when empty. Always pass one. |
30
30
  | `theme` | `FormUiTheme` | `"default"` | Rendered as `data-theme`. |
31
- | `step-animation-duration` | `string` | `"250ms"` | Marker slide duration. Ignored under `prefers-reduced-motion`. |
31
+ | `step-animation-duration` | `string` | `"250ms"` | Marker slide duration; also times the icon colour swap (see Behaviour). Ignored under `prefers-reduced-motion`. |
32
32
  | `style-class-passthrough` | `string \| string[]` | `[]` | Classes on the root. |
33
33
 
34
34
  Option `id`s are used as element ids (and as a class on each icon), so they must be unique on the
@@ -66,6 +66,12 @@ const options = ref<IFormMultipleOptions>({
66
66
  - The marker is measured from the first option's width on mount and fades in after ~250ms. It
67
67
  isn't re-measured on resize, so changing the icon-size/padding tokens after mount (e.g. toggling
68
68
  a class) leaves the marker at its old size.
69
+ - Icon colour swaps are timed to the marker: the newly active icon waits half of
70
+ `stepAnimationDuration`, then fades to the active colour over the other half, so it lands as the
71
+ marker arrives rather than turning white on the bare track. The previous icon fades back straight
72
+ away. The marker takes the same time whatever the distance, so no per-index delay is needed.
73
+ - Option circles are sized by their content (1em icon + equal padding) with `place-items: center`,
74
+ not `aspect-ratio`; older iPad Safari mis-centred the icons under the `aspect-ratio` version.
69
75
  - Marker gradients only exist for the values `system`, `light` and `dark`; other values get the
70
76
  plain `--triple-toggle-switch-marker-surface` background.
71
77
 
@@ -59,6 +59,8 @@ The three valid values are:
59
59
  | `"light"` | Forces light theme regardless of OS setting |
60
60
  | `"dark"` | Forces dark theme regardless of OS setting |
61
61
 
62
+ The attribute only switches schemes; the layer ships light colour values only. The app supplies the dark values, see `theming-dark-mode.md`.
63
+
62
64
  ## Notes
63
65
 
64
66
  - `onMounted` is used to read `localStorage` — `currentColourScheme` is always `"auto"` during SSR and hydrates client-side. Do not read it server-side.
@@ -32,10 +32,12 @@ 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
+ ├── 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); line-clamp tokens for overflowing text; rollout status
36
+ ├── theming-dark-mode.md — add a dark scheme in a consumer app (layer is light-only): enable plumbing, full dark token set without light-dark(), light-dark() alternative, light-only pinning
36
37
  ├── colour-scheme-disable.md — disable light/dark scheme support in a consumer app
37
38
  ├── component-dynamic-slots.md — named dynamic slots ($slots iteration) vs indexed dynamic slots (itemCount pattern)
38
39
  ├── component-naming.md — Control/Field naming convention (InputNumber + InputNumberField, no Core/Default/WithLabel suffixes), rename procedure, backlog with consumer usage
40
+ ├── component-compliance-checklist.md — definition of done for every component (tier, props, tokens, CONSUMER-STYLING.md layout, tests, story, skill doc, snippet, a11y, i18n, naming, pitfalls); shared by /new-component and /migrate-component
39
41
  ├── component-local-style-override.md — overriding a component from a consumer app: public tokens on an ancestor, unscoped page blocks, per-instance class/passthrough, consumer `<style scoped>` + :deep() rules, never @layer overrides
40
42
  ├── component-prop-driven-container-layout.md — vary CSS grid layout inside @container queries using data-* attribute selectors
41
43
  ├── page-transitions.md — pageTransition/layoutTransition setup; the self-wrapped <NuxtLayout> + layout:false anti-pattern that pulls the header/nav into the transition boundary; fade CSS
@@ -80,6 +82,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
80
82
  ├── page-hero-highlights.md — PageHeroHighlights template: hero + highlights strip grid, CSS custom property theming
81
83
  ├── slider-gallery.md — SliderGallery: full-screen image carousel, IGalleryData, pause-on-hover/reduced-motion auto-advance, prefixed classes, CSS tokens
82
84
  ├── services-card.md — ServicesCard props (incl. eyebrowConfig/heroConfig), actions slot, CSS tokens, page boilerplate
85
+ ├── google-reviews.md — GoogleReviews + GoogleReviewCard: Google Places reviews in a scrolling row; env vars, server route, useGoogleReviews, props, slots, tokens, Google terms
83
86
  ├── services-card-grid.md — ServicesCardGrid props, config pass-through, CSS tokens, full page boilerplate
84
87
  ├── service-summary-grid.md — ServiceSummaryGrid props, useAlternateReverse zigzag layout, page boilerplate
85
88
  ├── service-summary.md — ServiceSummary props, summary-link slot, DisplayPill duration/price (renamed/stripped from ServicesSection — full mode is now ServiceDetail)
@@ -137,6 +140,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
137
140
  ├── input-button.md — InputButton (renamed from InputButtonCore 2026-09-28): button/NuxtLink/a, variants, icon slots, readonly (aria-disabled, activation blocked), tokens
138
141
  ├── input-range-core.md — InputRangeCore: native range-slider primitive, markers/datalist slots, dead --theme-form-range-accent-color token fixed, CSS token API; Variants section covers InputRangeDefault
139
142
  ├── input-number.md — InputNumber: native number-input primitive, left/right step-button slots, missing base class/placeholder/mismatched slot-selector bugs fixed, CSS token API; Variants section covers InputNumberField
143
+ ├── input-otp.md — InputOtp: one-time-code digit boxes (paste/autofill spreading, Backspace/arrow/Home/End nav, Enter submits, leading zeros kept, complete event, hidden input); Variants section covers InputOtpField
140
144
  ├── form-field.md — FormField: layout wrapper for one form control (width cap, gutter, field spacing), data-width/data-has-gutter/data-invalid hooks, dead .underline rule removed, CSS token API
141
145
  ├── form-fieldset.md — FormFieldset: shared fieldset+legend for checkbox/radio groups, groupRole prop (hardcoded radiogroup bug fixed), aria-required only on radiogroup, dead description slot removed, legend token API
142
146
  ├── input-error.md — InputError: red error strip every *Field wrapper renders under its control, data-visible/data-detached/data-input-variant hooks, generic .inner/.message class names prefixed, dead compact prop and hidden-state border CSS removed, icon prop, CSS token API
@@ -339,5 +339,5 @@ npm install
339
339
  - If security modules are not wanted, omit `nuxt-security` from `dependencies` and remove it
340
340
  from `modules` and the `security` config block in `nuxt.config.ts`. Same for `@nuxtjs/robots`.
341
341
  - The `colourScheme.enabled: false` default is correct for most consumer apps — enable only if
342
- the app needs light/dark switching.
342
+ the app needs light/dark switching (then follow `theming-dark-mode.md`: the layer ships light values only).
343
343
  - Check the latest `srcdev-nuxt-components` version on npm before writing `package.json`.
@@ -254,8 +254,8 @@ In `app/assets/styles/setup/02.colours/index.css` (create if it doesn't exist):
254
254
  --theme-chroma: var(--palette-gold-chroma);
255
255
 
256
256
  /* Page-level tokens — readable named steps, not raw oklch */
257
- --colour-text-accent: light-dark(var(--gold-09), var(--gold-04));
258
- --colour-text-eyebrow: light-dark(var(--gold-09), var(--gold-04));
257
+ --colour-text-accent: var(--gold-09);
258
+ --colour-text-eyebrow: var(--gold-09);
259
259
  }
260
260
 
261
261
  /* Optional: make it available as a data-theme variant too */
@@ -295,37 +295,10 @@ approach whenever you need `--gold-09` style references in your CSS.
295
295
 
296
296
  ## Light / dark mode
297
297
 
298
- Semantic slots use `light-dark()`, which responds to `color-scheme` on `html` (set by the layer's
299
- `_head.css`):
300
-
301
- ```css
302
- html {
303
- color-scheme: light dark; /* OS preference */
304
- &.light { color-scheme: light; }
305
- &.dark { color-scheme: dark; }
306
- }
307
- ```
308
-
309
- Use `light-dark()` in your own override values too:
310
-
311
- ```css
312
- :where(html) {
313
- --theme-hue: 85;
314
- --theme-chroma: 0.20;
315
- --page-bg: light-dark(var(--slate-00), var(--slate-08));
316
- }
317
- ```
318
-
319
- **Multi-value caveat**: `light-dark()` cannot contain comma-separated values (e.g. box-shadow
320
- lists). Use intermediate scalar vars:
321
-
322
- ```css
323
- --_shadow-a: light-dark(0.08, 0.5);
324
- --my-shadow: 0 8px 32px rgba(0, 0, 0, var(--_shadow-a));
325
- ```
326
-
327
- The `useColourScheme()` composable toggles the `.light` / `.dark` class on `<html>`. See
328
- `composable-colour-scheme.md` for the full API.
298
+ The layer ships light values only (since 2026-10-04): no token uses `light-dark()`, because older
299
+ iPad Safari doesn't support it. Dark mode is the consumer app's decision. `theming-dark-mode.md`
300
+ has the full setup: enabling the scheme plumbing, the complete dark token set to copy (works on
301
+ older Safari), and the shorter `light-dark()` alternative for apps that don't need it.
329
302
 
330
303
  ## Hue angle reference
331
304
 
@@ -53,6 +53,36 @@ an inline fallback, even if that fallback happens to be a Tier 1 or Tier 2 token
53
53
  colour, fixed on purpose across states), reuse the base token — don't manufacture a second name
54
54
  for a value that never diverges.
55
55
 
56
+ ## Line-clamp tokens
57
+
58
+ Text that could overflow (names, titles, descriptions, body text) gets one public token per
59
+ element, `--{component}-{element}-line-clamp`. It covers both ellipsis cases: `1` is single-line
60
+ ellipsis, any other number caps the lines with an ellipsis on the last, `none` shows everything.
61
+ Prefer this over a prop: line count is presentation, and CSS handles it without a re-render.
62
+
63
+ ```css
64
+ .google-review-card-author-name {
65
+ display: -webkit-box;
66
+ -webkit-box-orient: vertical;
67
+ overflow: hidden;
68
+ overflow-wrap: anywhere; /* unbroken strings still wrap before the clamp applies */
69
+ -webkit-line-clamp: var(--google-reviews-author-line-clamp, 2);
70
+ line-clamp: var(--google-reviews-author-line-clamp, 2);
71
+ }
72
+ ```
73
+
74
+ - Default to `none` unless the layout clearly needs a cap (a row of equal-height cards, say). Use
75
+ `none` for "no clamp", never a large number like `100` (`ServicesCard`'s
76
+ `--description-line-clamp: 100` predates this; switch it to `none` when it's next migrated).
77
+ - Don't clamp text that must stay readable: legal or attribution text, error messages, link text
78
+ that names its destination.
79
+ - Clamped text stays in the DOM, so screen readers read all of it; sighted users lose the rest,
80
+ which is why `none` is the default. If a clamped item has nowhere else to read it in full,
81
+ consider a "read more" link or a `title`.
82
+ - `display: -webkit-box` makes an inline element (`<a>`, `<span>`) block-level; check the
83
+ layout around it.
84
+ - Document it in `CONSUMER-STYLING.md` with its default and the `1`/`none` options.
85
+
56
86
  ## Multi-level fallback chains are fine
57
87
 
58
88
  A component that wants to *look like* another component's default (rather than duplicating a
@@ -0,0 +1,243 @@
1
+ # Dark Mode in a Consumer App
2
+
3
+ ## Overview
4
+
5
+ Since 2026-10-04 this layer ships **light colour values only**. No token in the layer uses
6
+ `light-dark()`, because older iPad Safari doesn't support it. A custom property holding an
7
+ unsupported `light-dark()` is stored without complaint, then every property that reads it becomes
8
+ invalid at computed-value time: transparent backgrounds, `currentColor` borders, no console error.
9
+
10
+ Dark mode is the consumer app's decision. The layer still provides all the switching plumbing:
11
+
12
+ | Part | What it does |
13
+ |---|---|
14
+ | `runtimeConfig.public.colourScheme.enabled` | Turns the plumbing on. `new-app-scaffold.md` defaults it to `false`, so set it to `true` (see `colour-scheme-disable.md` for the config shape) |
15
+ | Head script (`modules/colour-scheme.ts`, `utils/colour-scheme-init.ts`) | Before first paint, sets `html[data-color-scheme]` to the saved `auto`, `light` or `dark` (default `auto`) |
16
+ | `useColourScheme()` | Reads/writes the saved scheme. See `composable-colour-scheme.md` |
17
+ | `DisplayThemeSwitch` | Ready-made system/light/dark picker. See `components/display-theme-switch.md` |
18
+
19
+ What the app supplies is the dark **values**, and the CSS `color-scheme` property. The layer
20
+ doesn't declare `color-scheme` at all (removed 2026-10-04), so browsers render native UI
21
+ (scrollbars, date and select pickers, form control internals) light unless the app says
22
+ otherwise. There are two ways to do it.
23
+
24
+ ---
25
+
26
+ ## Option A: attribute + media query (works on older Safari)
27
+
28
+ Redeclare the tokens you want dark under `html[data-color-scheme="dark"]`, and again inside a
29
+ `prefers-color-scheme: dark` media query for `auto`. The two blocks hold the same declarations;
30
+ that duplication is the cost of not using `light-dark()`.
31
+
32
+ Put it in its own file, imported **last** in the app's `03.theming/index.css` (see
33
+ `consumer-styles-structure.md`). The selectors are wrapped in `:where()` like every other consumer
34
+ token file, so they have zero specificity and win over the app's own light values by coming later.
35
+
36
+ ```css
37
+ /* setup/03.theming/index.css */
38
+ @import "./_default.css";
39
+ @import "./_forms.css";
40
+ @import "./_dark.css"; /* last */
41
+ ```
42
+
43
+ ### Full token set
44
+
45
+ These are the dark values the layer used to ship, so starting from this file gives you back the
46
+ previous built-in dark mode exactly. Delete what you don't need, or swap in your own palette's steps
47
+ (`--gold-04` etc.). If your `_default.css` changed a page-level token to another palette (e.g.
48
+ `--colour-text-accent: var(--gold-09)`), use that palette's step here too.
49
+
50
+ ```css
51
+ /* Dark scheme: token values for html[data-color-scheme="dark"], and "auto" on a dark OS. */
52
+
53
+ /* ---- 1. Page-level, a11y and input tokens: html only ---- */
54
+ :where(html[data-color-scheme="dark"]) {
55
+ color-scheme: dark; /* native UI goes dark too */
56
+
57
+ --page-bg: var(--slate-08);
58
+ --colour-text-default: var(--slate-01);
59
+ --colour-text-accent: var(--blue-05);
60
+ --colour-text-eyebrow: var(--blue-05);
61
+ --colour-link-default: var(--blue-02);
62
+ --colour-link-hover: var(--blue-03);
63
+
64
+ --theme-input-surface: var(--slate-10);
65
+ --theme-input-surface-hover: var(--slate-09);
66
+ --theme-input-text-color-normal: var(--slate-01);
67
+ --theme-input-placeholder: var(--slate-04);
68
+
69
+ --focus-box-shadow-colour-on: var(--slate-00);
70
+ --box-shadow-on: 0 0 0 0.3rem var(--slate-00);
71
+
72
+ /* Only if you use StepperList */
73
+ --stepper-list-counter-circle-text: var(--blue-02);
74
+ --stepper-list-counter-circle-border: var(--blue-02);
75
+ --stepper-list-counter-disc-background: var(--blue-02);
76
+ --stepper-list-counter-disc-border: var(--blue-02);
77
+ --stepper-list-counter-square-text: var(--blue-02);
78
+ --stepper-list-counter-square-border: var(--blue-02);
79
+ --stepper-list-connector-color: var(--blue-02);
80
+ --stepper-list-icon-color: var(--blue-02);
81
+
82
+ /* Only if you use TreatmentConsultant */
83
+ --treatment-consultant-primary-colour: var(--amber-02);
84
+ --treatment-consultant-primary-foreground: var(--amber-02);
85
+ --treatment-consultant-muted-colour: var(--amber-05);
86
+ --treatment-consultant-muted-foreground: var(--amber-05);
87
+ --treatment-consultant-background: var(--amber-10);
88
+ --treatment-consultant-foreground: var(--amber-02);
89
+ --treatment-consultant-border-colour: var(--amber-03);
90
+ --treatment-consultant-checked-surface-colour: var(--green-10);
91
+ --treatment-consultant-checked-stroke-colour: var(--green-03);
92
+ --treatment-consultant-conflict-surface-colour: var(--sunset-09);
93
+ --treatment-consultant-conflict-stroke-colour: var(--sunset-03);
94
+ }
95
+
96
+ /* ---- 2. Semantic slots: html AND every themed element ---- */
97
+ :where(
98
+ html[data-color-scheme="dark"],
99
+ html[data-color-scheme="dark"] [data-theme],
100
+ html[data-color-scheme="dark"] [data-invalid]
101
+ ) {
102
+ --theme-surface: var(--colour-theme-9);
103
+ --theme-surface-inverted: var(--colour-theme-2);
104
+ --theme-surface-hover: var(--colour-theme-7);
105
+ --theme-accent: var(--colour-theme-4);
106
+ --theme-surface-subtle: var(--colour-theme-9);
107
+ --theme-border: var(--colour-theme-5);
108
+ --theme-border-focus: var(--colour-theme-3);
109
+ --theme-ring: var(--colour-theme-9);
110
+ --theme-text: var(--colour-theme-2);
111
+ --theme-text-inverted: var(--colour-theme-7);
112
+ }
113
+
114
+ /* ---- 3. Warning keeps its AA-safe step 6 surface in both schemes ---- */
115
+ :where(html[data-color-scheme="dark"] [data-theme="warning"]) {
116
+ --theme-surface: var(--colour-theme-6);
117
+ --theme-surface-hover: var(--colour-theme-7);
118
+ }
119
+
120
+ /* ---- 4. Form error tokens ---- */
121
+ :where(
122
+ html[data-color-scheme="dark"] [data-invalid],
123
+ html[data-color-scheme="dark"] [data-theme="error"]
124
+ ) {
125
+ --theme-error-surface: var(--red-07);
126
+ --theme-error-border: var(--red-07);
127
+ --theme-error-outline: var(--red-05);
128
+ }
129
+
130
+ /* ---- 5. "auto" on a dark OS: repeat blocks 1-4 with data-color-scheme="auto" ---- */
131
+ @media (prefers-color-scheme: dark) {
132
+ :where(html[data-color-scheme="auto"]) {
133
+ /* block 1 declarations */
134
+ }
135
+ :where(
136
+ html[data-color-scheme="auto"],
137
+ html[data-color-scheme="auto"] [data-theme],
138
+ html[data-color-scheme="auto"] [data-invalid]
139
+ ) {
140
+ /* block 2 declarations */
141
+ }
142
+ :where(html[data-color-scheme="auto"] [data-theme="warning"]) {
143
+ /* block 3 declarations */
144
+ }
145
+ :where(
146
+ html[data-color-scheme="auto"] [data-invalid],
147
+ html[data-color-scheme="auto"] [data-theme="error"]
148
+ ) {
149
+ /* block 4 declarations */
150
+ }
151
+ }
152
+ ```
153
+
154
+ ### Why the selectors look like that
155
+
156
+ - **Semantic slots must be redeclared on `[data-theme]` and `[data-invalid]` elements**, not only
157
+ on `html`. The layer declares them on all three (`_theme-slots.css`) so `var(--colour-theme-N)`
158
+ resolves against each themed element's own hue. A dark value set only on `html` would be
159
+ computed once with the default hue and inherited, and every `data-theme="success"`/`"error"`
160
+ element would turn the default blue.
161
+ - **`[data-theme="warning"]` needs its own block** because the layer gives warning a fixed step 6
162
+ surface (the lightest orange step where light text still passes AA). Block 2 would otherwise
163
+ overwrite it.
164
+ - **No `data-color-scheme` attribute at all** only happens if the plumbing is disabled. If you
165
+ render before the head script can run (rare), add `html:not([data-color-scheme])` next to the
166
+ `auto` selector in block 5.
167
+
168
+ ### Component tokens
169
+
170
+ Components whose own public tokens default to fixed light values (e.g.
171
+ `--clipped-panel-background-colour`, `--glass-panel-*`, `--carousel-flip-*`,
172
+ `--dashboard-quad-grid-*`) don't change with the semantic slots. Add any you use to block 1 (and
173
+ block 5). Each component's `CONSUMER-STYLING.md` lists its colour tokens.
174
+
175
+ ---
176
+
177
+ ## Option B: `light-dark()` (shorter, modern browsers only)
178
+
179
+ If the app doesn't need to support older Safari (`light-dark()` needs Safari 17.5+, Chrome 123+,
180
+ Firefox 120+), set each token once with both values. `light-dark()` picks its branch from the
181
+ `color-scheme` property, which the layer doesn't set, so the app declares it, following the
182
+ attribute:
183
+
184
+ ```css
185
+ html {
186
+ color-scheme: light dark; /* "auto": follow the OS */
187
+
188
+ &[data-color-scheme="light"] { color-scheme: light; }
189
+ &[data-color-scheme="dark"] { color-scheme: dark; }
190
+ }
191
+
192
+ :where(html) {
193
+ --page-bg: light-dark(var(--slate-00), var(--slate-08));
194
+ --colour-text-default: light-dark(var(--slate-09), var(--slate-01));
195
+ }
196
+
197
+ :where(html, [data-theme], [data-invalid]) {
198
+ --theme-surface: light-dark(var(--colour-theme-7), var(--colour-theme-9));
199
+ --theme-text: light-dark(var(--colour-theme-9), var(--colour-theme-2));
200
+ /* ...the rest of block 2 from Option A, each as light-dark(layer value, dark value) */
201
+ }
202
+ ```
203
+
204
+ The light value for each token is the layer's current default (see
205
+ `app/assets/styles/setup/03.theming/` and `a11y/_variables.css` in the package). The same rules
206
+ apply: semantic slots on `[data-theme]`/`[data-invalid]` too, and a separate rule for
207
+ `[data-theme="warning"]`.
208
+
209
+ **Multi-value caveat**: `light-dark()` can't hold comma-separated values (e.g. a box-shadow list).
210
+ Put the varying part in a scalar var:
211
+
212
+ ```css
213
+ --_shadow-a: light-dark(0.08, 0.5);
214
+ --my-shadow: 0 8px 32px rgba(0, 0, 0, var(--_shadow-a));
215
+ ```
216
+
217
+ On a browser without `light-dark()`, Option B fails the same silent way described in the
218
+ Overview. Don't mix: if older Safari matters for any page, use Option A everywhere.
219
+
220
+ ---
221
+
222
+ ## Light-only apps
223
+
224
+ If the app has no dark mode, leave `colourScheme.enabled: false` (the scaffold default) and add
225
+ nothing. With no `color-scheme` declared anywhere, native UI stays light even on a dark OS.
226
+
227
+ ---
228
+
229
+ ## Storybook in a consumer app
230
+
231
+ This repo's own Storybook is set up as a consumer and is a working reference:
232
+ `.storybook/color-scheme.css` holds blocks 1-4, and `.storybook/preview.ts` turns its "Follow OS"
233
+ toolbar option into `data-color-scheme="light"`/`"dark"` using `matchMedia`, so it needs no block 5.
234
+ Those files aren't in the npm package; the token set above is the same content.
235
+
236
+ ---
237
+
238
+ ## Related
239
+
240
+ - `composable-colour-scheme.md`: `useColourScheme()` API
241
+ - `colour-scheme-disable.md`: the `enabled` flag and turning everything off
242
+ - `theming-colour-ramps.md`: ramps, semantic slots, palettes for your dark values
243
+ - `consumer-styles-structure.md`: where `_dark.css` sits in the app's CSS tree
@@ -120,10 +120,10 @@ Create `app/assets/styles/setup/03.theming/_default.css`:
120
120
  --theme-chroma: var(--palette-gold-chroma);
121
121
 
122
122
  /* Page-level tokens — readable named steps, not raw oklch */
123
- --page-bg: light-dark(var(--slate-00), var(--slate-08));
124
- --colour-text-default: light-dark(var(--slate-09), var(--slate-01));
125
- --colour-text-accent: light-dark(var(--gold-09), var(--gold-04));
126
- --colour-text-eyebrow: light-dark(var(--gold-09), var(--gold-04));
123
+ --page-bg: var(--slate-00);
124
+ --colour-text-default: var(--slate-09);
125
+ --colour-text-accent: var(--gold-09);
126
+ --colour-text-eyebrow: var(--gold-09);
127
127
  }
128
128
  ```
129
129
 
@@ -172,8 +172,8 @@ export default defineNuxtConfig({
172
172
  The layer's `theme-ramp.css` declares the formula on `:where(html, [data-theme], [data-invalid])`.
173
173
  When your override sets `--theme-hue`/`--theme-chroma` on `:where(html)`, every `--colour-theme-*`
174
174
  step recalculates to your palette. Semantic slots (`--theme-surface`, `--theme-text`, etc.) are
175
- declared on that same selector using `light-dark()` — light/dark mode continues to work without
176
- any extra declarations.
175
+ declared on that same selector with light values only. The layer ships no dark scheme; to add
176
+ one, see `theming-dark-mode.md`.
177
177
 
178
178
  The generated `_gold.css` gives you a named alias for each step (`--gold-09` etc.) so your
179
179
  page-level token values stay readable. The values match what the formula would produce for
@@ -183,7 +183,6 @@ that hue/chroma pair.
183
183
 
184
184
  - Define `--colour-theme-0` through `--colour-theme-10` — the formula handles it
185
185
  - Redeclare `--theme-surface`, `--theme-border`, `--theme-ring` etc. — they auto-update
186
- - Write separate light/dark files — use `light-dark()` in a single file
187
186
  - Override any button or input component tokens — they all consume semantic slots
188
187
 
189
188
  ## rem sizing
@@ -88,8 +88,8 @@ Page-level tokens:
88
88
  ```css
89
89
  /* Make primary buttons darker/more saturated (step-7/9 defaults, step-9/7 hover) */
90
90
  :where(html) {
91
- --theme-surface: light-dark(oklch(48% 0.18 85), oklch(32% 0.20 85));
92
- --theme-surface-hover: light-dark(oklch(32% 0.18 85), oklch(48% 0.20 85));
91
+ --theme-surface: oklch(48% 0.18 85);
92
+ --theme-surface-hover: oklch(32% 0.18 85);
93
93
  }
94
94
  ```
95
95
 
@@ -97,9 +97,9 @@ Page-level tokens:
97
97
 
98
98
  ```css
99
99
  :where(html) {
100
- --theme-input-surface: light-dark(oklch(0.99 0 0), oklch(0.12 0.01 255));
101
- --theme-input-text-color-normal: light-dark(oklch(0.20 0.01 255), oklch(0.95 0.01 255));
102
- --theme-input-placeholder: light-dark(oklch(0.60 0.01 255), oklch(0.40 0.01 255));
100
+ --theme-input-surface: oklch(0.99 0 0);
101
+ --theme-input-text-color-normal: oklch(0.20 0.01 255);
102
+ --theme-input-placeholder: oklch(0.60 0.01 255);
103
103
  }
104
104
  ```
105
105
 
@@ -107,9 +107,9 @@ Page-level tokens:
107
107
 
108
108
  ```css
109
109
  :where(html) {
110
- --page-bg: light-dark(oklch(0.98 0 0), oklch(0.10 0 0));
111
- --colour-text-default: light-dark(oklch(0.20 0 0), oklch(0.93 0 0));
112
- --colour-text-accent: light-dark(oklch(0.30 0.16 85), oklch(0.65 0.18 85));
110
+ --page-bg: oklch(0.98 0 0);
111
+ --colour-text-default: oklch(0.20 0 0);
112
+ --colour-text-accent: oklch(0.30 0.16 85);
113
113
  }
114
114
  ```
115
115
 
@@ -140,8 +140,8 @@ To restrict an override to a specific section, scope to a wrapper class instead
140
140
 
141
141
  .hero-cta {
142
142
  /* Just these buttons use a different surface colour */
143
- --theme-surface: light-dark(oklch(48% 0.16 85), oklch(32% 0.18 85));
144
- --theme-surface-hover: light-dark(oklch(32% 0.14 85), oklch(48% 0.16 85));
143
+ --theme-surface: oklch(48% 0.16 85);
144
+ --theme-surface-hover: oklch(32% 0.14 85);
145
145
  }
146
146
  ```
147
147
 
@@ -160,6 +160,6 @@ To change how `data-theme="success"` looks without affecting other themes:
160
160
  ## Notes
161
161
 
162
162
  - Only override the tokens you actually need — everything else falls back to layer defaults
163
- - Use `light-dark()` in a single rule rather than separate `:where(html.light)` / `:where(html.dark)` blocks — the layer's `color-scheme` infrastructure handles mode switching
163
+ - The layer ships light values only (no `light-dark()`, for older iPad Safari). For dark mode, redeclare tokens under `html[data-color-scheme="dark"]` plus a `prefers-color-scheme` block for `auto`, or use `light-dark()` yourself if you accept the browser-support cost. Full setup in `theming-dark-mode.md`.
164
164
  - `--slate-*` neutral tokens come from the layer and do not need to be redefined unless you genuinely want different neutrals
165
165
  - For a full palette replacement (changing the layer default), use `theming-override-default.md` instead
@@ -0,0 +1,78 @@
1
+ {
2
+ "SRCDEV GoogleReviews Basic": {
3
+ "description": "GoogleReviews fed by useGoogleReviews() (needs NUXT_GOOGLE_REVIEWS_API_KEY and NUXT_GOOGLE_REVIEWS_PLACE_ID)",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<GoogleReviews :data=\"${1:googleReviews}\" />"
7
+ ]
8
+ },
9
+ "SRCDEV GoogleReviews Script": {
10
+ "description": "Fetch Google reviews from the layer's server route",
11
+ "scope": "typescript,javascript,vue",
12
+ "body": [
13
+ "const { data: ${1:googleReviews} } = await useGoogleReviews();"
14
+ ]
15
+ },
16
+ "SRCDEV GoogleReviews With Heading": {
17
+ "description": "GoogleReviews labelled by its own heading, optionally filtered",
18
+ "scope": "vue,html",
19
+ "body": [
20
+ "<GoogleReviews :data=\"${1:googleReviews}\" :min-rating=\"${2:0}\">",
21
+ " <template #heading=\"{ headingId }\">",
22
+ " <h2 :id=\"headingId\">${3:What clients say}</h2>",
23
+ " </template>",
24
+ "</GoogleReviews>"
25
+ ]
26
+ },
27
+ "SRCDEV GoogleReviews Custom Card": {
28
+ "description": "GoogleReviews with each card rendered through the card slot",
29
+ "scope": "vue,html",
30
+ "body": [
31
+ "<GoogleReviews :data=\"${1:googleReviews}\">",
32
+ " <template #card=\"{ review }\">",
33
+ " <GoogleReviewCard :review=\"review\" :style-class-passthrough=\"['${2:my-review}']\" />",
34
+ " </template>",
35
+ "</GoogleReviews>"
36
+ ]
37
+ },
38
+ "SRCDEV GoogleReviews CSS Override": {
39
+ "description": "CSS override scaffold for GoogleReviews — scope to a page or section class. See CONSUMER-STYLING.md for the full token list.",
40
+ "scope": "css",
41
+ "body": [
42
+ ".$1my-page {",
43
+ " .google-reviews {",
44
+ " --google-reviews-gap: ;",
45
+ " --google-reviews-card-width: ;",
46
+ " --google-reviews-scroll-padding: ;",
47
+ " --google-reviews-scrollbar-width: ;",
48
+ " --google-reviews-scrollbar-thumb-colour: ;",
49
+ " --google-reviews-scrollbar-track-colour: ;",
50
+ " --google-reviews-summary-rating-font-size: ;",
51
+ " --google-reviews-focus-outline-width: ;",
52
+ " --google-reviews-focus-outline-colour: ;",
53
+ "",
54
+ " --google-reviews-card-gap: ;",
55
+ " --google-reviews-card-padding: ;",
56
+ " --google-reviews-card-border-width: ;",
57
+ " --google-reviews-card-border-colour: ;",
58
+ " --google-reviews-card-border-radius: ;",
59
+ " --google-reviews-card-surface: ;",
60
+ " --google-reviews-card-text-colour: ;",
61
+ " --google-reviews-avatar-surface: ;",
62
+ " --google-reviews-avatar-text-colour: ;",
63
+ " --google-reviews-author-font-weight: ;",
64
+ " --google-reviews-author-line-clamp: ;",
65
+ " --google-reviews-text-line-height: ;",
66
+ " --google-reviews-text-line-clamp: ;",
67
+ " --google-reviews-link-colour: ;",
68
+ "",
69
+ " --google-reviews-star-size: ;",
70
+ " --google-reviews-star-colour: ;",
71
+ " --google-reviews-star-empty-colour: ;",
72
+ " --google-reviews-meta-font-size: ;",
73
+ " --google-reviews-meta-text-colour: ;",
74
+ " }",
75
+ "}"
76
+ ]
77
+ }
78
+ }