srcdev-nuxt-components 9.5.2 → 9.5.4
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.
- package/.claude/skills/colour-scheme-disable.md +1 -1
- package/.claude/skills/component-compliance-checklist.md +214 -0
- package/.claude/skills/component-inline-action-button.md +2 -2
- package/.claude/skills/components/canvas-switcher.md +5 -1
- package/.claude/skills/components/carousel-flip.md +3 -3
- package/.claude/skills/components/clipped-panel.md +2 -2
- package/.claude/skills/components/dashboard-quad-grid.md +2 -2
- package/.claude/skills/components/dashboard-stats-grid.md +1 -1
- package/.claude/skills/components/deep-expanding-menu-classic.md +1 -1
- package/.claude/skills/components/deep-expanding-menu.md +1 -1
- package/.claude/skills/components/eyebrow-text.md +6 -4
- package/.claude/skills/components/google-reviews.md +193 -0
- package/.claude/skills/components/input-number.md +4 -0
- package/.claude/skills/components/input-otp.md +120 -0
- package/.claude/skills/components/section-parallax.md +1 -1
- package/.claude/skills/components/select-menu.md +9 -5
- package/.claude/skills/components/services-card.md +25 -2
- package/.claude/skills/composable-colour-scheme.md +2 -0
- package/.claude/skills/index.md +5 -1
- package/.claude/skills/new-app-scaffold.md +1 -1
- package/.claude/skills/storybook-add-story.md +5 -0
- package/.claude/skills/theming-colour-ramps.md +6 -33
- package/.claude/skills/theming-component-token-pattern.md +30 -0
- package/.claude/skills/theming-dark-mode.md +243 -0
- package/.claude/skills/theming-override-default.md +6 -7
- package/.claude/skills/theming-partial-override.md +11 -11
- package/.vscode/srcdev-component-eyebrow-text.code-snippets +4 -0
- package/.vscode/srcdev-component-google-reviews.code-snippets +78 -0
- package/.vscode/srcdev-component-input-otp.code-snippets +47 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +13 -0
- package/.vscode/srcdev-component-services-card.code-snippets +38 -7
- package/README.md +81 -41
- package/app/assets/styles/setup/01.config/_head.css +0 -9
- package/app/assets/styles/setup/03.theming/_default.css +29 -29
- package/app/assets/styles/setup/03.theming/_error.css +3 -3
- package/app/assets/styles/setup/03.theming/_theme-slots.css +13 -16
- package/app/assets/styles/setup/a11y/_variables.css +2 -2
- package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +47 -13
- package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +5 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +4 -1
- package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +7 -7
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +10 -0
- package/app/components/02.molecules/select-menu/SelectMenu.vue +15 -1
- package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +35 -0
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +9 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +3 -3
- package/app/components/03.organisms/google-reviews/CONSUMER-STYLING.md +110 -0
- package/app/components/03.organisms/google-reviews/GoogleReviewCard.vue +161 -0
- package/app/components/03.organisms/google-reviews/GoogleReviews.vue +275 -0
- package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +323 -0
- package/app/components/03.organisms/google-reviews/tests/GoogleReviewCard.spec.ts +104 -0
- package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +232 -0
- package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +9 -0
- package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +43 -0
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +28 -3
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +111 -18
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +122 -0
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +4 -1
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +100 -19
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +12 -3
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +1 -1
- package/app/components/05.forms/input-number/InputNumber.vue +2 -2
- package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +59 -0
- package/app/components/05.forms/input-otp/InputOtp.vue +254 -0
- package/app/components/05.forms/input-otp/InputOtpField.vue +106 -0
- package/app/components/05.forms/input-otp/stories/InputOtp.stories.ts +100 -0
- package/app/components/05.forms/input-otp/stories/InputOtpField.stories.ts +163 -0
- package/app/components/05.forms/input-otp/tests/InputOtp.spec.ts +275 -0
- package/app/components/05.forms/input-otp/tests/InputOtpField.spec.ts +76 -0
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +151 -33
- package/app/composables/tests/useAriaDescribedById.spec.ts +5 -5
- package/app/composables/useGoogleReviews.ts +4 -0
- package/app/types/components/google-reviews.d.ts +18 -0
- package/app/types/components/index.ts +1 -0
- package/nuxt.config.ts +9 -9
- package/package.json +9 -8
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# InputOtp Component
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`InputOtp` is a one-time-code entry control: a row of single-digit boxes for verification codes
|
|
6
|
+
sent by email or SMS (sign-in, two-factor, email confirmation). It has no label, description or
|
|
7
|
+
error rendering of its own; that's composed by the `InputOtpField` variant below.
|
|
8
|
+
|
|
9
|
+
Most consumers should reach for **InputOtpField**.
|
|
10
|
+
|
|
11
|
+
Location: `app/components/05.forms/input-otp/`. Styling: `CONSUMER-STYLING.md` in that folder.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Behaviour
|
|
16
|
+
|
|
17
|
+
- Only digits are accepted. Typing a digit fills the box and moves focus to the next one.
|
|
18
|
+
- Typing into a box that already holds a digit replaces it (focusing a box selects its contents).
|
|
19
|
+
- Backspace in an empty box clears the previous box and moves focus there; Backspace in a filled
|
|
20
|
+
box only clears that box.
|
|
21
|
+
- Pasting spreads the digits across the boxes, strips non-digits and truncates to `length`. A
|
|
22
|
+
full-length paste always fills from the first box; a shorter one fills from the focused box.
|
|
23
|
+
Focus lands on the last box filled.
|
|
24
|
+
- Autofill works: the first box has `autocomplete="one-time-code"`, and several characters landing
|
|
25
|
+
in one box (iOS/Android SMS autofill) are spread the same way as a paste.
|
|
26
|
+
- Enter submits the parent form via `form.requestSubmit()` (with the native implicit submission
|
|
27
|
+
suppressed, so it never submits twice). Several single-character inputs don't otherwise get
|
|
28
|
+
implicit submission when the form has no submit button.
|
|
29
|
+
- Left/Right arrows move between boxes; Home/End jump to the first/last box.
|
|
30
|
+
- The first box is focused on mount only when `autofocus` is set. When `fieldHasError` turns on,
|
|
31
|
+
focus moves to the first box so the user can retype straight away.
|
|
32
|
+
- `complete` fires with the code whenever a change leaves every box filled.
|
|
33
|
+
- Each box has a visually hidden label built from `digitLabel` (translatable). In
|
|
34
|
+
`InputOtpField` the fieldset legend names the group; on its own, `groupLabel` adds
|
|
35
|
+
`role="group"` + `aria-label`.
|
|
36
|
+
- On error every box gets `aria-invalid="true"` and the `aria-describedby` ids.
|
|
37
|
+
- With `name` set, a hidden input carries the full code, so a native form submit sends one value.
|
|
38
|
+
The boxes themselves have no `name`.
|
|
39
|
+
- **Leading zeros are kept.** The model is a `string` and the boxes are `type="text"` with
|
|
40
|
+
`inputmode="numeric"`; nothing is ever converted to a number, so `"012345"` stays `"012345"`.
|
|
41
|
+
- `length` is clamped to 1–12. Non-digits and overflow in an incoming model value are ignored
|
|
42
|
+
for display (they show as empty boxes).
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Props reference
|
|
47
|
+
|
|
48
|
+
> Write camelCase props hyphenated in templates (`:field-has-error`, `:digit-label`).
|
|
49
|
+
|
|
50
|
+
| Prop (template form) | Type | Default | Notes |
|
|
51
|
+
|------|------|---------|-------|
|
|
52
|
+
| `:id` | `string` | (required) | Box ids are `{id}-0`, `{id}-1`, ... |
|
|
53
|
+
| `:name` | `string` | `""` | Name of the hidden input holding the full code. No hidden input when empty. |
|
|
54
|
+
| `:length` | `number` | `6` | Number of boxes, clamped 1–12. |
|
|
55
|
+
| `:autofocus` | `boolean` | `false` | Focus the first box on mount. Use when the field appears as the direct result of a user action (e.g. after submitting an email step). |
|
|
56
|
+
| `:required` | `boolean` | `false` | Native `required` on every box. |
|
|
57
|
+
| `:group-label` | `string` | `""` | When set, root gets `role="group"` and this `aria-label`. Leave empty inside `InputOtpField`. |
|
|
58
|
+
| `:digit-label` | `string` | `"Digit {index} of {length}"` | Per-box hidden label; `{index}` (1-based) and `{length}` are replaced. |
|
|
59
|
+
| `:field-has-error` | `boolean` | `false` | `data-invalid` on the root, `aria-invalid` on each box, focuses the first box when it turns on. |
|
|
60
|
+
| `:aria-describedby` | `string` | `""` | Forwarded to every box. |
|
|
61
|
+
| `:theme` | `FormUiTheme` | `"default"` | `data-theme` on the root. |
|
|
62
|
+
| `:input-variant` | `InputUiVariant` | `"normal"` | `normal` (bordered boxes) or `underlined`. `outlined` has no CSS, same as the other `05.forms` inputs. |
|
|
63
|
+
| `:style-class-passthrough` | `string \| string[]` | `[]` | Classes on the `.input-otp` root. |
|
|
64
|
+
|
|
65
|
+
### v-model
|
|
66
|
+
|
|
67
|
+
`v-model`: `string`, **required**. Digits only; unfilled trailing boxes are trimmed, so a
|
|
68
|
+
half-entered code is e.g. `"123"`. A gap in the middle (box 2 cleared while box 3 is filled)
|
|
69
|
+
is a space.
|
|
70
|
+
|
|
71
|
+
### Events
|
|
72
|
+
|
|
73
|
+
| Event | Payload | When |
|
|
74
|
+
|------|------|------|
|
|
75
|
+
| `complete` | `code: string` | A change leaves every box filled. Use it to auto-submit. |
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Variants
|
|
80
|
+
|
|
81
|
+
### InputOtpField
|
|
82
|
+
|
|
83
|
+
`FormFieldset` (the label is the `<legend>`) + `InputDescription` + `InputOtp` + `InputError`,
|
|
84
|
+
with ids linked through `useAriaDescribedById`. Root class `.input-otp-field` (on the fieldset).
|
|
85
|
+
|
|
86
|
+
| Prop | Type | Default | Notes |
|
|
87
|
+
|------|------|---------|-------|
|
|
88
|
+
| `name` | `string` | (required) | Used for the ids and the hidden input's name. |
|
|
89
|
+
| `label` | `string` | (required) | Fieldset legend. |
|
|
90
|
+
| `errorMessage` | `string \| object` | `""` | Shown in `InputError` when `fieldHasError`. |
|
|
91
|
+
| `length`, `autofocus`, `required`, `digitLabel`, `fieldHasError`, `theme`, `inputVariant`, `styleClassPassthrough` | | | As on `InputOtp`; passthrough goes on the fieldset. |
|
|
92
|
+
|
|
93
|
+
Slots: `descriptionText`, `descriptionHtml` (linked via `aria-describedby`). Emits `complete`.
|
|
94
|
+
|
|
95
|
+
```vue
|
|
96
|
+
<form @submit.prevent="verify">
|
|
97
|
+
<InputOtpField
|
|
98
|
+
v-model="code"
|
|
99
|
+
name="code"
|
|
100
|
+
label="Verification code"
|
|
101
|
+
autofocus
|
|
102
|
+
:error-message="codeError"
|
|
103
|
+
:field-has-error="Boolean(codeError)"
|
|
104
|
+
@complete="verify"
|
|
105
|
+
>
|
|
106
|
+
<template #descriptionText>Enter the 6-digit code we sent to your email.</template>
|
|
107
|
+
</InputOtpField>
|
|
108
|
+
</form>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Migrating from an app-local `InputOtp`
|
|
114
|
+
|
|
115
|
+
This was ported from a consumer app's own `InputOtp.vue` (2026-10-04). Differences: the app version
|
|
116
|
+
took `id` + `label` in one component; use `InputOtpField` with `name` instead of `id`. It always
|
|
117
|
+
focused the first box on mount; pass `autofocus` to keep that. Its boxes were named `{id}-{index}`;
|
|
118
|
+
read the code from `v-model` or the hidden input (`name`) instead. Rename the app's file away (or
|
|
119
|
+
delete it) once on the layer release that ships this, or the app's own `InputOtp` shadows the
|
|
120
|
+
layer's.
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
|
174
|
-
(Chrome 114+, Firefox 125+, Safari 17+)
|
|
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
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
| `tag` | `"div" \| "section" \| "article"` | `"div"` | no |
|
|
13
13
|
| `href` | `string` | `undefined` | no |
|
|
14
14
|
| `external` | `boolean` | `false` | no |
|
|
15
|
+
| `titlesWithinImageWrapper` | `boolean` | `false` | no |
|
|
15
16
|
| `eyebrowConfig` | `ServicesCardEyebrowConfig` | `{}` | no |
|
|
16
17
|
| `heroConfig` | `ServicesCardHeroConfig` | `{}` | no |
|
|
17
18
|
| `durationText` | `string` | `undefined` | no |
|
|
@@ -30,6 +31,17 @@ If an `actions` slot is provided, the card stays as a static `tag` element (no h
|
|
|
30
31
|
|
|
31
32
|
`is-clickable` is added to the root class list when whole-card-clickable mode is active, giving `cursor: pointer` and removing default link colour/underline. It's also what gates the border/outline hover/focus interaction-state tokens — see "Interaction states (whole-card-clickable only)" in `CONSUMER-STYLING.md`; they're a no-op when the card isn't clickable, since only `.is-clickable` has a `:hover`/`:focus-visible` rule.
|
|
32
33
|
|
|
34
|
+
### Titles over the image (`titlesWithinImageWrapper`)
|
|
35
|
+
|
|
36
|
+
When `true`, the eyebrow and title move out of `.details-wrapper` and sit over the bottom of the image instead. The image wrapper is a `GridStack`: the image is layer 1, and `.image-wrapper-details` (eyebrow + title) is layer 2. The description, meta row and actions stay below the image.
|
|
37
|
+
|
|
38
|
+
- A gradient scrim behind the text keeps it readable. Its colour comes from `--image-wrapper-details-scrim-colour` (default black), and `--image-wrapper-details-scrim` replaces the whole background (`none` removes it).
|
|
39
|
+
- Text colour uses `contrast-color()` against the scrim colour where supported (black or white, whichever reads better), falling back to white. It reads the scrim colour only, not the photo, so if you remove the scrim, set `--image-wrapper-details-text-colour` yourself.
|
|
40
|
+
- The eyebrow's gradient fill is turned off over the image; it takes the text colour (or `--image-wrapper-details-eyebrow-text-colour`).
|
|
41
|
+
- The eyebrow's default `fontSize` is `"medium"` in this mode (vs `"large"` below the image). `eyebrowConfig`/`heroConfig` still apply.
|
|
42
|
+
|
|
43
|
+
See "Titles over the image" in `CONSUMER-STYLING.md` for the token list.
|
|
44
|
+
|
|
33
45
|
### EyebrowConfig
|
|
34
46
|
|
|
35
47
|
Exported as `ServicesCardEyebrowConfig` (and `ServicesCardHeroConfig` below) from `srcdev-nuxt-components` since 2026-09-28.
|
|
@@ -37,7 +49,7 @@ Exported as `ServicesCardEyebrowConfig` (and `ServicesCardHeroConfig` below) fro
|
|
|
37
49
|
| Key | Type | Default |
|
|
38
50
|
| ---------- | ---------------------------------- | --------- |
|
|
39
51
|
| `tag` | `"p" \| "div" \| "span"` | `"div"` |
|
|
40
|
-
| `fontSize` | `"large" \| "medium" \| "small"` | `"large"` |
|
|
52
|
+
| `fontSize` | `"large" \| "medium" \| "small"` | `"large"` (`"medium"` with `titlesWithinImageWrapper`) |
|
|
41
53
|
|
|
42
54
|
### HeroConfig
|
|
43
55
|
|
|
@@ -159,6 +171,16 @@ See `CONSUMER-STYLING.md` for the full token list — every token is a plain pub
|
|
|
159
171
|
consumed directly at its point of use (no `--_`-prefixed private indirection layer), so all of
|
|
160
172
|
them work both globally (`:root`) and scoped like the example above.
|
|
161
173
|
|
|
174
|
+
## Titles over the image usage
|
|
175
|
+
|
|
176
|
+
```vue
|
|
177
|
+
<ServicesCard :service-data="service" titles-within-image-wrapper>
|
|
178
|
+
<template #actions="{ serviceData }">
|
|
179
|
+
<InputButton variant="secondary" button-text="Enquire" :href="`/services/${serviceData.slug}`" />
|
|
180
|
+
</template>
|
|
181
|
+
</ServicesCard>
|
|
182
|
+
```
|
|
183
|
+
|
|
162
184
|
## Whole-card clickable usage
|
|
163
185
|
|
|
164
186
|
```vue
|
|
@@ -173,6 +195,7 @@ No `actions` slot — the entire card renders as an anchor (`NuxtLink` for the i
|
|
|
173
195
|
- The `Service` type is imported from `~/types/types.services`.
|
|
174
196
|
- Root markup is two rows (`grid-template-rows: auto 1fr`): `.image-wrapper`, then a `.details-wrapper` (`display: flex; flex-direction: column`) holding the eyebrow, title, description, and a `.footer` wrapper. Description length is controlled by `--description-line-clamp` (default effectively unclamped) rather than a fixed-height grid row.
|
|
175
197
|
- `.footer` groups the meta row and the `actions` slot and gets `margin-block-start: auto`, pinning them to the bottom of the card. Combined with the root's `1fr` details row, this means when `ServicesCardGrid`'s default `align-items: stretch` makes a card taller than its own content (to match a taller sibling in the same row), the extra height goes to `.footer`'s top margin rather than leaving whitespace below the actions slot — so the meta row and actions/button line up across a row of cards regardless of each card's description length. This does the visual job of CSS subgrid without needing a subgrid chain across `ServicesCard`/`ServicesCardGrid` (which would also break whenever cards in a row don't all render the same optional rows — `.meta` and `actions` are both conditional).
|
|
176
|
-
- Image has a `3/4` aspect ratio with a subtle scale-on-hover effect.
|
|
198
|
+
- Image has a `3/4` aspect ratio with a subtle scale-on-hover effect, triggered by hovering the image only (not the whole card). When the card is a whole-card link styled with its own hover effect (e.g. `--services-card-transform-hover`), set `--image-wrapper-border-image-zoom-transform: none` so the two effects don't stack; the "Dark Overlay Cards" story does this. Keep the zoom for the `actions`-slot layout, where the card itself has no hover state. The hover is on `.image-wrapper`, not the image, so it still fires when the overlaid titles cover the image. `.image-wrapper-details` is `position: relative; z-index: 1` because the image's hover `transform` would otherwise paint over it, and `.image-wrapper` sets `isolation: isolate` so that z-index stays inside the card.
|
|
177
199
|
- `.services-card`, `.image-wrapper`, `.details-wrapper`, and `.footer` all set `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot button label, since `InputButton`'s `.button-text` is `white-space: nowrap` with no ellipsis — forces its content's min-content width up through the flex/grid chain and widens that one card's grid column in `ServicesCardGrid` wider than its siblings (the image just rides along on the widened column; it isn't the actual cause). If you see one card/column wider than the rest with cut-off content, check for a long unbreakable string in a slot before assuming it's an image sizing issue.
|
|
178
200
|
- Usually consumed via `ServicesCardGrid` rather than directly.
|
|
201
|
+
- The "Dark Overlay Cards" story shows a full restyle using tokens only: dark panel, flush image, titles over the image, hover lift (`--services-card-transform-hover`), and EyebrowText typography tokens (`--eyebrow-text-font-style`, `-letter-spacing`, etc.) set on the card so they inherit into its eyebrow.
|
|
@@ -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.
|
package/.claude/skills/index.md
CHANGED
|
@@ -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`.
|
|
@@ -277,6 +277,11 @@ Key points:
|
|
|
277
277
|
(`html[data-color-scheme="light"]`, off-white `--slate-00` stage) by default, whatever the OS
|
|
278
278
|
setting. Use the toolbar "Colour scheme" menu (Light / Dark / Follow OS) to check dark mode.
|
|
279
279
|
Don't hardcode a stage background in a story to get a light canvas; it's already the default.
|
|
280
|
+
- **Edge-aware components need `layout: "fullscreen"`** in the meta's `parameters`. Storybook's
|
|
281
|
+
default `"padded"` layout insets the canvas by 1rem, which masks viewport gutters, full-bleed
|
|
282
|
+
tracks and edge alignment (`PageRow`, `SliderGallery`). To swap canvas widths inside a story,
|
|
283
|
+
add the `CanvasSwitcher` decorator from `PageRow.stories.ts` (container-sized components only;
|
|
284
|
+
see `components/canvas-switcher.md`).
|
|
280
285
|
- Use `table: { category: "..." }` in `argTypes` when a component has many props — it groups
|
|
281
286
|
them in the Storybook controls panel (e.g. `"Model"`, `"Basic"`, `"Validation"`, `"Styling"`, `"Slots"`).
|
|
282
287
|
- Export multiple named stories (`Default`, `WithError`, `Outlined`, etc.) when you want
|
|
@@ -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:
|
|
258
|
-
--colour-text-eyebrow:
|
|
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
|
-
|
|
299
|
-
`
|
|
300
|
-
|
|
301
|
-
|
|
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
|