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
@@ -0,0 +1,47 @@
1
+ {
2
+ "SRCDEV InputOtp Basic": {
3
+ "description": "Bare InputOtp one-time-code boxes (no legend/description/error wrapper)",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<InputOtp",
7
+ " id=\"$1otp\"",
8
+ " v-model=\"$2code\"",
9
+ " name=\"$3code\"",
10
+ " :length=\"${4:6}\"",
11
+ " group-label=\"$5Verification code\"",
12
+ "/>"
13
+ ]
14
+ },
15
+ "SRCDEV InputOtpField Basic": {
16
+ "description": "InputOtpField: labelled one-time-code field with description and error support",
17
+ "scope": "vue,html",
18
+ "body": [
19
+ "<InputOtpField",
20
+ " v-model=\"$1code\"",
21
+ " name=\"$2code\"",
22
+ " label=\"$3Verification code\"",
23
+ " :length=\"${4:6}\"",
24
+ " autofocus",
25
+ " :error-message=\"$5errors.code\"",
26
+ " :field-has-error=\"!!$5errors.code\"",
27
+ " @complete=\"$6onComplete\"",
28
+ ">",
29
+ " <template #descriptionText>$7Enter the code we sent to your email.</template>",
30
+ "</InputOtpField>"
31
+ ]
32
+ },
33
+ "SRCDEV InputOtp CSS Override": {
34
+ "description": "CSS override scaffold for InputOtp tokens",
35
+ "scope": "css",
36
+ "body": [
37
+ ".$1my-page {",
38
+ " --input-otp-gap: $2;",
39
+ " --input-otp-box-inline-size: $3;",
40
+ " --input-otp-box-block-size: $4;",
41
+ " --input-otp-font-size: $5;",
42
+ " --input-otp-border: $6;",
43
+ " --input-otp-border-focus: $7;",
44
+ "}"
45
+ ]
46
+ }
47
+ }
@@ -35,6 +35,19 @@
35
35
  "/>"
36
36
  ]
37
37
  },
38
+ "SRCDEV SelectMenu Underlined": {
39
+ "description": "SelectMenu — text + chevron with an underlined trigger, matching InputSelect's underlined variant",
40
+ "scope": "vue,html",
41
+ "body": [
42
+ "<SelectMenu",
43
+ " v-model=\"${1:filterValue}\"",
44
+ " :options=\"${2:filterOptions}\"",
45
+ " label=\"${3:Choose a service}\"",
46
+ " :show-icon=\"false\"",
47
+ " input-variant=\"underlined\"",
48
+ "/>"
49
+ ]
50
+ },
38
51
  "SRCDEV SelectMenu Multi-Select": {
39
52
  "description": "SelectMenu — checkbox-per-option multi-select, v-model is an array",
40
53
  "scope": "vue,html",
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## NOTE
8
8
 
9
- Althought this repo is public and feel free to do what you wish with it, this has been developed for use with websites we develop.
9
+ Although this repo is public and feel free to do what you wish with it, this has been developed for use with websites we develop.
10
10
 
11
11
  ## Install Nuxt Components layer
12
12
 
@@ -58,7 +58,7 @@ To ensure skills are always up to date, VSCode snippets are installed, and `nuxt
58
58
  2. `nuxt prepare` — generates Nuxt type declarations
59
59
  3. `setup:claude` — copies skills into `.claude/skills/srcdev-nuxt-components/`
60
60
 
61
- > **Note**: The snippet copy must run from your consumer app's postinstall, not the layer's. npm doesn't invoke postinstall hooks for dependencies, so each consumer app is responsible for copying snippets into its own `.vscode/` folder. If your app uses a standalone env flag for `nuxt prepare` (e.g. `NUXT_STANDALONE=true`), add it before `nuxt prepare`. This is project-specific — check your own `nuxt.config.ts` to confirm whether it is needed.
61
+ > **Note**: The snippet copy must run from your consumer app's postinstall, not the layer's. npm doesn't invoke postinstall hooks for dependencies, so each consumer app is responsible for copying snippets into its own `.vscode/` folder. Don't set `SRCDEV_STANDALONE` in a consumer app: that flag is only for running this layer on its own (it enables dev-only modules and the ramp watcher).
62
62
 
63
63
  ---
64
64
 
@@ -77,6 +77,31 @@ via `npm run setup:claude`.
77
77
 
78
78
  ---
79
79
 
80
+ ## Components
81
+
82
+ Components live in `app/components/`, grouped into tiers:
83
+
84
+ | Folder | Storybook title prefix |
85
+ | -------------- | ---------------------- |
86
+ | `01.atoms` | `Atoms/` |
87
+ | `02.molecules` | `Molecules/` |
88
+ | `03.organisms` | `Organisms/` |
89
+ | `04.templates` | `Templates/` |
90
+ | `05.forms` | `Forms/` |
91
+
92
+ Components are auto-imported with `pathPrefix: false`, so the tier folder never appears in the tag name.
93
+
94
+ **Naming**: a bare form control is `<Name>` (e.g. `InputNumber`) and its labelled wrapper is `<Name>Field` (e.g. `InputNumberField`). Older `Core` / `Default` / `WithLabel` suffixes are being renamed to this convention; see `.claude/skills/component-naming.md` for the backlog.
95
+
96
+ Each component ships with:
97
+
98
+ - **`CONSUMER-STYLING.md`** next to the `.vue` file, listing its public CSS custom properties and how to override them
99
+ - **a Storybook story**, the only place components are demonstrated (this repo has no demo pages)
100
+ - **a skill doc** in `.claude/skills/components/`, copied into your app by `npm run setup:claude`
101
+ - **a VS Code snippet** in `.vscode/srcdev-component-{name}.code-snippets`, copied into your app by `postinstall`
102
+
103
+ ---
104
+
80
105
  ## Consumer App Configuration
81
106
 
82
107
  Configuration options for apps extending this layer. All options go in the consumer's `nuxt.config.ts` under `runtimeConfig.public` and can also be set via environment variable.
@@ -108,6 +133,40 @@ When disabled, no `data-color-scheme` attribute is set on `<html>` and `useColou
108
133
 
109
134
  > See [.claude/skills/colour-scheme-disable.md](.claude/skills/colour-scheme-disable.md) for the full guide.
110
135
 
136
+ ### Analytics
137
+
138
+ Call `useAnalytics()` from a layout, page or component and use `trackEvent(name, params)` to fire events. Only Google Analytics (GA4) is implemented today; call sites never name the provider, so adding another later won't change them.
139
+
140
+ ```ts
141
+ // nuxt.config.ts
142
+ runtimeConfig: {
143
+ public: {
144
+ analytics: {
145
+ provider: "google-analytics",
146
+ googleAnalytics: { id: "G-XXXXXXXXXX" },
147
+ },
148
+ },
149
+ },
150
+ ```
151
+
152
+ Or via environment variable:
153
+
154
+ ```bash
155
+ NUXT_PUBLIC_ANALYTICS_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
156
+ ```
157
+
158
+ With no ID set, `trackEvent` does nothing.
159
+
160
+ ### WhatsApp
161
+
162
+ `useWhatsApp().openWhatsApp(fields)` opens a WhatsApp chat pre-filled with the given label/value pairs. Set the number in international format, without `+` or spaces:
163
+
164
+ ```bash
165
+ NUXT_PUBLIC_WHATSAPP_NUMBER=447700900000
166
+ ```
167
+
168
+ Without a number it logs a warning and does nothing.
169
+
111
170
  ---
112
171
 
113
172
  ## Colour System
@@ -131,7 +190,8 @@ computes the scale from the two params:
131
190
  Scale direction: **00 = lightest, 10 = darkest**. Chroma tapers at both ends and peaks at step 06.
132
191
 
133
192
  Nine semantic slots (`--theme-surface`, `--theme-accent`, `--theme-text`, `--theme-ring`, etc.) are derived from the
134
- scale using `light-dark()` and are shared by all components. Theme variants (`data-theme="success"`,
193
+ scale and are shared by all components. They ship light values only (no `light-dark()`, which older iPad
194
+ Safari lacks); dark mode is left to the consuming app, see `.claude/skills/theming-dark-mode.md`. Theme variants (`data-theme="success"`,
135
195
  `"warning"`, `"error"`) swap `--theme-hue` and `--theme-chroma` on their element; the formula
136
196
  re-evaluates locally so each themed element gets its own full palette without affecting the page.
137
197
 
@@ -222,8 +282,8 @@ In your `app/assets/styles/setup/02.colours/index.css` (create if it doesn't exi
222
282
  --theme-chroma: var(--palette-gold-chroma);
223
283
 
224
284
  /* Page-level tokens — readable named steps, not raw oklch */
225
- --colour-text-accent: light-dark(var(--gold-09), var(--gold-04));
226
- --colour-text-eyebrow: light-dark(var(--gold-09), var(--gold-04));
285
+ --colour-text-accent: var(--gold-09);
286
+ --colour-text-eyebrow: var(--gold-09);
227
287
  }
228
288
  ```
229
289
 
@@ -270,45 +330,13 @@ npm run check:ramps # CI: fail if generated CSS is out of date
270
330
  | Full ramp architecture, formula details, hue drift | `.claude/skills/theming-colour-ramps.md` |
271
331
  | Full palette swap for a consumer app | `.claude/skills/theming-override-default.md` |
272
332
  | Partial token override (palette shift, buttons, inputs) | `.claude/skills/theming-partial-override.md` |
333
+ | Add a dark scheme (layer ships light values only) | `.claude/skills/theming-dark-mode.md` |
273
334
  | Disable light/dark mode support | `.claude/skills/colour-scheme-disable.md` |
274
335
 
275
336
  Skills are available in your project after running `npm run setup:claude`.
276
337
 
277
338
  ---
278
339
 
279
- ## Known Build / Production Issues
280
-
281
- ### `ERR_MODULE_NOT_FOUND: vue/index.mjs` — 500 on every request (Node 22)
282
-
283
- **Symptom**: After `npm run build`, running `node .output/server/index.mjs` returns HTTP 500 on every page. The terminal shows:
284
-
285
- ```text
286
- Error [ERR_MODULE_NOT_FOUND]: Cannot find module
287
- '.output/server/node_modules/vue/index.mjs'
288
- Did you mean to import
289
- '.output/server/node_modules/.nitro/vue@3.5.34/dist/vue.cjs.prod.js'?
290
- ```
291
-
292
- **Root cause**: Nitro's dependency tracer externalises `vue` and copies it to `.output/server/node_modules/vue/`, but only traces the CJS build (`dist/vue.cjs.prod.js`). Vue's `package.json` exports map resolves the `"import"` + `"node"` condition (used by Node 22 when importing from an ES module) to `./index.mjs` — a file that was never copied. This is a Nitro tracing bug exposed by Node 22's stricter ESM condition matching, made more likely by `vue.runtimeCompiler: true` (which changes how Nuxt aliases Vue at build time, causing Nitro to fall back to externalising it as a node_modules package).
293
-
294
- **Fix** (already applied in this repo's `nuxt.config.ts`):
295
-
296
- ```ts
297
- nitro: {
298
- externals: {
299
- inline: ["vue", "@vue/runtime-core", "@vue/runtime-dom", "@vue/reactivity", "@vue/shared", "@vue/server-renderer"],
300
- },
301
- },
302
- ```
303
-
304
- This tells Nitro to bundle these packages inline rather than externalise them, which sidesteps the runtime resolution entirely.
305
-
306
- **Consumer apps**: This config is not gated behind `isStandalone`, so it is inherited automatically by any app that extends this layer. No consumer-side action is required.
307
-
308
- **When it's safe to remove**: If a future Nitro release fixes the dependency tracer so that all export-condition files are copied correctly, the `inline` list can be removed. Verify by checking that `.output/server/node_modules/vue/index.mjs` exists after a clean build without the config.
309
-
310
- ---
311
-
312
340
  ## Development Environment (`.vscode`)
313
341
 
314
342
  The `.vscode` directory contains Visual Studio Code configuration files to ensure a consistent development experience across the project:
@@ -428,7 +456,7 @@ npm run test:update
428
456
 
429
457
  ### Visual Regression Tests (Playwright)
430
458
 
431
- Runs pixel-level screenshot comparisons against Storybook. Requires Storybook to be running first.
459
+ Runs pixel-level screenshot comparisons against Storybook. Requires Storybook to be running first. Coverage is currently limited to a few components (`InputButton`, `HeroText`, `EyebrowText`, `ContentDocs`); specs live in each component's `playwright/` folder as `*.playwright.ts`.
432
460
 
433
461
  ```bash
434
462
  # 1. Start Storybook
@@ -462,12 +490,13 @@ npx playwright show-report
462
490
 
463
491
  ## Storybook
464
492
 
465
- Storybook is used for isolated component development and as the target for visual regression tests. It runs as a separate Vite-based dev server.
493
+ Storybook (v10, via `@nuxtjs/storybook`) is the only demo surface for this library, and the target for visual regression tests. The deployed build is at [storybook.srcdev.co.uk](https://storybook.srcdev.co.uk).
466
494
 
467
495
  ### Scripts
468
496
 
469
497
  ```bash
470
- # Start Storybook dev server (http://localhost:6006)
498
+ # Start Storybook dev server (http://localhost:6006). `npm run dev` does the same after
499
+ # regenerating the colour ramps; there is no `nuxt dev` entry point.
471
500
  npm run storybook
472
501
 
473
502
  # Build a static Storybook (outputs to storybook-static/)
@@ -483,6 +512,16 @@ npm run storybook:cache:clean
483
512
  > After clearing the cache, restart with `npm run storybook`. The cache clear is particularly
484
513
  > useful when changes inside `@layer` CSS blocks are not reflected in the running dev server.
485
514
 
515
+ ### MCP server for AI agents
516
+
517
+ `@storybook/addon-mcp` exposes the stories over MCP while the dev server is running, so a coding agent can look up components, their props and story examples instead of guessing. The endpoint is `http://localhost:6006/mcp`. To connect Claude Code:
518
+
519
+ ```bash
520
+ claude mcp add --transport http storybook http://localhost:6006/mcp
521
+ ```
522
+
523
+ See the [Storybook MCP docs](https://storybook.js.org/docs/next/ai/mcp/overview) for other clients.
524
+
486
525
  ### Fonts
487
526
 
488
527
  `@nuxt/fonts` is disabled in Storybook (detected via `process.env.STORYBOOK` in `nuxt.config.ts`).
@@ -492,5 +531,6 @@ Fonts are served instead from local files in `.storybook/public/_fonts/`, declar
492
531
  | ---------------- | ------ | -------------------------------------------- |
493
532
  | Poppins | TTF | `.storybook/public/_fonts/poppins/` |
494
533
  | Playfair Display | woff2 | `.storybook/public/_fonts/playfair-display/` |
534
+ | Mono MMM 5 | TTF | `public/fonts/` |
495
535
 
496
536
  To add a new font, see [.claude/skills/storybook-add-font.md](.claude/skills/storybook-add-font.md).
@@ -1,15 +1,6 @@
1
1
  html {
2
- color-scheme: light dark;
3
2
  interpolate-size: allow-keywords;
4
3
 
5
- &[data-color-scheme="light"] {
6
- color-scheme: light;
7
- }
8
-
9
- &[data-color-scheme="dark"] {
10
- color-scheme: dark;
11
- }
12
-
13
4
  font-size: 62.5%;
14
5
 
15
6
  transition:
@@ -6,10 +6,10 @@
6
6
  /* ===========================================
7
7
  GENERIC BODY VARIABLES
8
8
  =========================================== */
9
- --page-bg: light-dark(var(--slate-00), var(--slate-08));
10
- --colour-text-default: light-dark(var(--slate-09), var(--slate-01));
11
- --colour-text-accent: light-dark(var(--blue-09), var(--blue-05));
12
- --colour-text-eyebrow: light-dark(var(--blue-09), var(--blue-05));
9
+ --page-bg: var(--slate-00);
10
+ --colour-text-default: var(--slate-09);
11
+ --colour-text-accent: var(--blue-09);
12
+ --colour-text-eyebrow: var(--blue-09);
13
13
 
14
14
  /* ===========================================
15
15
  HERO / EYEBROW TEXT VARIABLES
@@ -21,46 +21,46 @@
21
21
  STEPPER LIST VARIABLES
22
22
  =========================================== */
23
23
  --stepper-list-counter-circle-background: transparent;
24
- --stepper-list-counter-circle-text: light-dark(var(--blue-09), var(--blue-02));
25
- --stepper-list-counter-circle-border: light-dark(var(--blue-09), var(--blue-02));
26
- --stepper-list-counter-disc-background: light-dark(var(--blue-01), var(--blue-02));
24
+ --stepper-list-counter-circle-text: var(--blue-09);
25
+ --stepper-list-counter-circle-border: var(--blue-09);
26
+ --stepper-list-counter-disc-background: var(--blue-01);
27
27
  --stepper-list-counter-disc-text: var(--blue-09);
28
- --stepper-list-counter-disc-border: light-dark(var(--blue-01), var(--blue-02));
28
+ --stepper-list-counter-disc-border: var(--blue-01);
29
29
  --stepper-list-counter-square-background: transparent;
30
- --stepper-list-counter-square-text: light-dark(var(--blue-09), var(--blue-02));
31
- --stepper-list-counter-square-border: light-dark(var(--blue-09), var(--blue-02));
32
- --stepper-list-connector-color: light-dark(var(--blue-01), var(--blue-02));
33
- --stepper-list-icon-color: light-dark(var(--blue-09), var(--blue-02));
30
+ --stepper-list-counter-square-text: var(--blue-09);
31
+ --stepper-list-counter-square-border: var(--blue-09);
32
+ --stepper-list-connector-color: var(--blue-01);
33
+ --stepper-list-icon-color: var(--blue-09);
34
34
 
35
35
  /* ===========================================
36
36
  TREATMENT CONSULTANT VARIABLES
37
37
  =========================================== */
38
- --treatment-consultant-primary-colour: light-dark(var(--amber-05), var(--amber-02));
39
- --treatment-consultant-primary-foreground: light-dark(var(--amber-09), var(--amber-02));
40
- --treatment-consultant-muted-colour: light-dark(var(--amber-03), var(--amber-05));
41
- --treatment-consultant-muted-foreground: light-dark(var(--amber-07), var(--amber-05));
42
- --treatment-consultant-background: light-dark(var(--amber-01), var(--amber-10));
43
- --treatment-consultant-foreground: light-dark(var(--amber-09), var(--amber-02));
44
- --treatment-consultant-border-colour: light-dark(var(--blue-02), var(--amber-03));
45
- --treatment-consultant-checked-surface-colour: light-dark(var(--green-03), var(--green-10));
46
- --treatment-consultant-checked-stroke-colour: light-dark(var(--green-09), var(--green-03));
47
- --treatment-consultant-conflict-surface-colour: light-dark(var(--sunset-03), var(--sunset-09));
48
- --treatment-consultant-conflict-stroke-colour: light-dark(var(--sunset-09), var(--sunset-03));
38
+ --treatment-consultant-primary-colour: var(--amber-05);
39
+ --treatment-consultant-primary-foreground: var(--amber-09);
40
+ --treatment-consultant-muted-colour: var(--amber-03);
41
+ --treatment-consultant-muted-foreground: var(--amber-07);
42
+ --treatment-consultant-background: var(--amber-01);
43
+ --treatment-consultant-foreground: var(--amber-09);
44
+ --treatment-consultant-border-colour: var(--blue-02);
45
+ --treatment-consultant-checked-surface-colour: var(--green-03);
46
+ --treatment-consultant-checked-stroke-colour: var(--green-09);
47
+ --treatment-consultant-conflict-surface-colour: var(--sunset-03);
48
+ --treatment-consultant-conflict-stroke-colour: var(--sunset-09);
49
49
 
50
50
  /* ===========================================
51
51
  ANCHOR LINK VARIABLES
52
52
  =========================================== */
53
- --colour-link-default: light-dark(var(--blue-10), var(--blue-02));
54
- --colour-link-hover: light-dark(var(--blue-09), var(--blue-03));
53
+ --colour-link-default: var(--blue-10);
54
+ --colour-link-hover: var(--blue-09);
55
55
 
56
56
  /* ===========================================
57
57
  STRUCTURAL INPUT TOKENS
58
58
  Neutral slate surface — independent of theme colour
59
59
  =========================================== */
60
- --theme-input-surface: light-dark(var(--slate-00), var(--slate-10));
61
- --theme-input-surface-hover: light-dark(var(--slate-01), var(--slate-09));
62
- --theme-input-text-color-normal: light-dark(var(--slate-09), var(--slate-01));
63
- --theme-input-placeholder: light-dark(var(--slate-05), var(--slate-04));
60
+ --theme-input-surface: var(--slate-00);
61
+ --theme-input-surface-hover: var(--slate-01);
62
+ --theme-input-text-color-normal: var(--slate-09);
63
+ --theme-input-placeholder: var(--slate-05);
64
64
  --theme-checkbox-symbol-surface: var(--theme-input-surface);
65
65
  --input-error-margin-block-start: 0.25rem;
66
66
  }
@@ -8,7 +8,7 @@
8
8
  These are always error-red regardless of nesting context.
9
9
  =========================================== */
10
10
  --input-error-color: white;
11
- --theme-error-surface: light-dark(var(--red-06), var(--red-07));
12
- --theme-error-border: light-dark(var(--red-06), var(--red-07));
13
- --theme-error-outline: light-dark(var(--red-01), var(--red-05));
11
+ --theme-error-surface: var(--red-06);
12
+ --theme-error-border: var(--red-06);
13
+ --theme-error-outline: var(--red-01);
14
14
  }
@@ -1,21 +1,18 @@
1
1
  /* Theme semantic slots — 8 shared colour roles used by all themed components.
2
2
  Declared on the same selector as theme-ramp.css so the formula re-evaluates
3
- per [data-theme] element rather than being inherited from html. */
3
+ per [data-theme] element rather than being inherited from html.
4
+ Light values only, no light-dark(): dark mode is the consumer app's call. */
4
5
  :where(html, [data-theme], [data-invalid]) {
5
- --theme-surface: light-dark(var(--colour-theme-7), var(--colour-theme-9));
6
- --theme-surface-inverted: light-dark(var(--colour-theme-9), var(--colour-theme-2));
7
- --theme-surface-hover: light-dark(var(--colour-theme-9), var(--colour-theme-7));
8
- --theme-accent: light-dark(var(--colour-theme-5), var(--colour-theme-4));
9
- --theme-surface-subtle: light-dark(var(--colour-theme-1), var(--colour-theme-9));
10
- --theme-border: light-dark(var(--colour-theme-6), var(--colour-theme-5));
11
- --theme-border-focus: light-dark(var(--colour-theme-4), var(--colour-theme-3));
12
- --theme-ring: light-dark(var(--colour-theme-1), var(--colour-theme-9));
6
+ --theme-surface: var(--colour-theme-7);
7
+ --theme-surface-inverted: var(--colour-theme-9);
8
+ --theme-surface-hover: var(--colour-theme-9);
9
+ --theme-accent: var(--colour-theme-5);
10
+ --theme-surface-subtle: var(--colour-theme-1);
11
+ --theme-border: var(--colour-theme-6);
12
+ --theme-border-focus: var(--colour-theme-4);
13
+ --theme-ring: var(--colour-theme-1);
13
14
  --theme-on-surface: var(--colour-theme-0);
14
- --theme-text: light-dark(var(--colour-theme-9), var(--colour-theme-2));
15
- /* Light-mode branch was --colour-theme-9 — identical to --theme-surface-inverted's own
16
- light-mode branch (line 6), its only pairing (InputButton's .secondary, background +
17
- color), so secondary-button text was invisible (dark-on-dark) in light mode. Should be a
18
- light step to contrast against that dark inverted surface, matching how --theme-on-surface
19
- (line 13) contrasts against --theme-surface the same way in both modes. */
20
- --theme-text-inverted: light-dark(var(--colour-theme-0), var(--colour-theme-7));
15
+ --theme-text: var(--colour-theme-9);
16
+ /* Light step: pairs with the dark --theme-surface-inverted (InputButton .secondary). */
17
+ --theme-text-inverted: var(--colour-theme-0);
21
18
  }
@@ -1,8 +1,8 @@
1
1
  :where(html) {
2
2
  --focus-box-shadow-colour-off: transparent;
3
- --focus-box-shadow-colour-on: light-dark(var(--blue-10), var(--slate-00));
3
+ --focus-box-shadow-colour-on: var(--blue-10);
4
4
  --form-focus-box-shadow: 0 0 0 0.3rem;
5
5
 
6
6
  --box-shadow-off: 0 0 0 0.3rem transparent;
7
- --box-shadow-on: 0 0 0 0.3rem light-dark(var(--blue-10), var(--slate-00));
7
+ --box-shadow-on: 0 0 0 0.3rem var(--blue-10);
8
8
  }
@@ -218,7 +218,7 @@ export const InContext: Story = {
218
218
  align-items: center;
219
219
  gap: 1.2rem;
220
220
  padding: 1.2rem 1.6rem;
221
- border-bottom: 0.1rem solid light-dark(#e5e7eb, #374151);
221
+ border-bottom: 0.1rem solid rgb(128 128 128 / 0.35);
222
222
  "
223
223
  >
224
224
  <div
@@ -226,7 +226,7 @@ export const InContext: Story = {
226
226
  width: 3.6rem;
227
227
  height: 3.6rem;
228
228
  border-radius: 50%;
229
- background: light-dark(#e5e7eb, #374151);
229
+ background: rgb(128 128 128 / 0.35);
230
230
  display: grid;
231
231
  place-items: center;
232
232
  font-size: 1.4rem;
@@ -290,17 +290,17 @@ export const ItemCoreStandalone: Story = {
290
290
  template: `
291
291
  <div style="padding: 4rem; max-width: 32rem; margin: 0 auto;">
292
292
  <p style="margin: 0 0 1.2rem; font-size: 1.2rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.08em;">Button items</p>
293
- <div style="border: 0.1rem solid light-dark(#e5e7eb, #374151); border-radius: 0.8rem; overflow: hidden;">
293
+ <div style="border: 0.1rem solid rgb(128 128 128 / 0.35); border-radius: 0.8rem; overflow: hidden;">
294
294
  <ActionMenuItem
295
295
  label="Edit"
296
- style="border-bottom: 0.1rem solid light-dark(#e5e7eb, #374151);"
296
+ style="border-bottom: 0.1rem solid rgb(128 128 128 / 0.35);"
297
297
  @click="lastClick = 'Edit'"
298
298
  >
299
299
  <template #icon><Icon name="lucide:pencil" /></template>
300
300
  </ActionMenuItem>
301
301
  <ActionMenuItem
302
302
  label="Duplicate"
303
- style="border-bottom: 0.1rem solid light-dark(#e5e7eb, #374151);"
303
+ style="border-bottom: 0.1rem solid rgb(128 128 128 / 0.35);"
304
304
  @click="lastClick = 'Duplicate'"
305
305
  >
306
306
  <template #icon><Icon name="lucide:copy" /></template>
@@ -314,11 +314,11 @@ export const ItemCoreStandalone: Story = {
314
314
  </div>
315
315
 
316
316
  <p style="margin: 2.4rem 0 1.2rem; font-size: 1.2rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.08em;">Link items</p>
317
- <div style="border: 0.1rem solid light-dark(#e5e7eb, #374151); border-radius: 0.8rem; overflow: hidden;">
317
+ <div style="border: 0.1rem solid rgb(128 128 128 / 0.35); border-radius: 0.8rem; overflow: hidden;">
318
318
  <ActionMenuItem
319
319
  label="Internal link (/about)"
320
320
  href="/about"
321
- style="border-bottom: 0.1rem solid light-dark(#e5e7eb, #374151);"
321
+ style="border-bottom: 0.1rem solid rgb(128 128 128 / 0.35);"
322
322
  >
323
323
  <template #icon><Icon name="lucide:arrow-up-right" /></template>
324
324
  </ActionMenuItem>
@@ -14,6 +14,7 @@ global token so it matches the rest of the design system out of the box.
14
14
  | `--select-menu-trigger-padding-block` | `0.8rem` | Trigger vertical padding |
15
15
  | `--select-menu-trigger-padding-inline` | `1.2rem` | Trigger horizontal padding |
16
16
  | `--select-menu-trigger-border-width` | `0.1rem` | Trigger border thickness |
17
+ | `--select-menu-trigger-border-width-underlined` | `var(--form-element-border-bottom-width-underlined, 0.3rem)` | Bottom border thickness in the `underlined` variant |
17
18
  | `--select-menu-trigger-border` | `var(--theme-border)` | Trigger border colour (rest state) |
18
19
  | `--select-menu-trigger-border-focus` | `var(--theme-border-focus)` | Trigger border colour on hover/focus |
19
20
  | `--select-menu-trigger-border-radius` | `0.5rem` | Trigger corner rounding |
@@ -48,12 +49,30 @@ global token so it matches the rest of the design system out of the box.
48
49
  | `--select-menu-item-text-color` | `var(--theme-text)` | Option label colour |
49
50
  | `--select-menu-item-font-size` | `1.5rem` | Option label font size |
50
51
  | `--select-menu-item-surface-hover` | `var(--theme-input-surface-hover)` | Option row background on hover/focus |
52
+ | `--select-menu-item-surface-selected` | `var(--theme-surface-subtle)` | Background of selected/checked option rows (hover/focus still wins) |
53
+ | `--select-menu-item-text-color-selected` | `--select-menu-item-text-color` | Selected row label colour (the checkmark follows via `currentcolor`) |
54
+ | `--select-menu-item-font-weight` | inherited | Option row font weight |
55
+ | `--select-menu-item-font-weight-selected` | `--select-menu-item-font-weight` | Selected row font weight; the popover reserves the bolder width, so selecting doesn't resize it |
56
+ | `--select-menu-item-border-width` | `0` | Block (top/bottom) border width on **every** row, so a selected border never shifts layout |
57
+ | `--select-menu-item-border` | `transparent` | Resting row border colour |
58
+ | `--select-menu-item-border-selected` | `--select-menu-item-border` | Selected row border colour. Two adjacent selected rows show both borders where they meet |
59
+ | `--select-menu-item-text-color-hover` | the row's current colour | Label colour on hover/focus. Unset, a selected row keeps its selected colour |
60
+ | `--select-menu-item-border-hover` | the row's current border | Border colour on hover/focus. Unset, a selected row keeps its selected border |
51
61
  | `--select-menu-item-check-size` | `1.6rem` | Checkmark icon box size |
52
62
  | `--select-menu-item-check-color` | `currentcolor` | Checkmark icon colour |
53
63
  | `--select-menu-item-icon-size` | `1.8rem` | Per-option icon size |
54
64
 
55
65
  ---
56
66
 
67
+ ## State hooks
68
+
69
+ | Hook | Where | Meaning |
70
+ |---|---|---|
71
+ | `.normal` / `.underlined` | `.select-menu` root | The `inputVariant` prop. `underlined` removes the trigger's top and side borders and its corner radius, matching `InputSelect`'s underlined variant. |
72
+ | `:has(.select-menu-popover:popover-open)` | `.select-menu` root | Menu open (rotates the chevron). |
73
+
74
+ ---
75
+
57
76
  ## Global theming
58
77
 
59
78
  Create `assets/styles/setup/07.components/select-menu.css` in the consuming app and set tokens
@@ -174,4 +193,3 @@ story.
174
193
  the bottom of the viewport (`position-try-fallbacks: flip-block`).
175
194
  - **Chevron rotation** is pure CSS via `:has(.select-menu-popover:popover-open)` — no JS state
176
195
  needed for the visual, though `isOpen` is still tracked internally to drive `aria-expanded`.
177
-
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="select-menu" :class="[elementClasses]" :style="`--_anchor-name: ${anchorName}`">
2
+ <div class="select-menu" :class="[inputVariant, elementClasses]" :style="`--_anchor-name: ${anchorName}`">
3
3
  <button
4
4
  ref="triggerRef"
5
5
  :popovertarget="menuId"
@@ -41,6 +41,7 @@
41
41
  role="option"
42
42
  tabindex="-1"
43
43
  :aria-selected="isSelected(option)"
44
+ :data-label="option.label"
44
45
  @click="selectOption(option)"
45
46
  >
46
47
  <span class="select-menu-item-check" aria-hidden="true">
@@ -61,6 +62,7 @@
61
62
 
62
63
  <script setup lang="ts">
63
64
  import type { SelectMenuOption } from "~/types/components/select-menu";
65
+ import type { InputUiVariant } from "~/types/forms/types.forms";
64
66
 
65
67
  interface Props {
66
68
  /** The full list of selectable options. */
@@ -79,6 +81,8 @@ interface Props {
79
81
  multiple?: boolean;
80
82
  /** In multiple mode, update the trigger text to a comma-separated list of the currently checked options instead of leaving it fixed on placeholder/label. No effect outside multiple mode. */
81
83
  showSelectionInTrigger?: boolean;
84
+ /** Trigger border style, matching InputSelect: `normal` (bordered box) or `underlined` (bottom border only). */
85
+ inputVariant?: InputUiVariant;
82
86
  styleClassPassthrough?: string | string[];
83
87
  }
84
88
 
@@ -89,6 +93,7 @@ const props = withDefaults(defineProps<Props>(), {
89
93
  showChevron: true,
90
94
  multiple: false,
91
95
  showSelectionInTrigger: false,
96
+ inputVariant: "normal",
92
97
  styleClassPassthrough: () => [],
93
98
  });
94
99
 
@@ -273,6 +278,16 @@ watch(
273
278
  }
274
279
  }
275
280
 
281
+ &.underlined .select-menu-trigger {
282
+ border-block-start-width: 0;
283
+ border-inline-width: 0;
284
+ border-block-end-width: var(
285
+ --select-menu-trigger-border-width-underlined,
286
+ var(--form-element-border-bottom-width-underlined, 0.3rem)
287
+ );
288
+ border-radius: 0;
289
+ }
290
+
276
291
  &:has(.select-menu-popover:popover-open) .select-menu-trigger-chevron {
277
292
  transform: rotate(180deg);
278
293
  }
@@ -329,15 +344,50 @@ watch(
329
344
  gap: var(--select-menu-item-gap, 0.8rem);
330
345
  padding-block: var(--select-menu-item-padding-block, 1rem);
331
346
  padding-inline: var(--select-menu-item-padding-inline, 1.2rem);
347
+ --_text-color: var(--select-menu-item-text-color, var(--theme-text));
348
+ --_border: var(--select-menu-item-border, transparent);
349
+
332
350
  cursor: pointer;
333
- color: var(--select-menu-item-text-color, var(--theme-text));
351
+ color: var(--_text-color);
334
352
  font-family: var(--font-family);
335
353
  font-size: var(--select-menu-item-font-size, 1.5rem);
336
- transition: background-color var(--select-menu-transition-duration, 200ms) ease;
354
+ /* No fallback: unset means invalid at computed time, so the weight inherits. */
355
+ font-weight: var(--select-menu-item-font-weight);
356
+ border-block: var(--select-menu-item-border-width, 0) solid var(--_border);
357
+ transition:
358
+ background-color var(--select-menu-transition-duration, 200ms) ease,
359
+ border-color var(--select-menu-transition-duration, 200ms) ease,
360
+ color var(--select-menu-transition-duration, 200ms) ease;
361
+
362
+ /* Reserves the selected label width so a bolder selected weight doesn't resize the popover. */
363
+ &::after {
364
+ content: attr(data-label);
365
+ grid-column: 3;
366
+ grid-row: 1;
367
+ block-size: 0;
368
+ overflow: hidden;
369
+ visibility: hidden;
370
+ white-space: nowrap;
371
+ font-weight: var(--select-menu-item-font-weight-selected, var(--select-menu-item-font-weight));
372
+ }
373
+
374
+ &[aria-selected="true"] {
375
+ --_text-color: var(
376
+ --select-menu-item-text-color-selected,
377
+ var(--select-menu-item-text-color, var(--theme-text))
378
+ );
379
+ --_border: var(--select-menu-item-border-selected, var(--select-menu-item-border, transparent));
380
+
381
+ background-color: var(--select-menu-item-surface-selected, var(--theme-surface-subtle));
382
+ font-weight: var(--select-menu-item-font-weight-selected, var(--select-menu-item-font-weight));
383
+ }
337
384
 
385
+ /* Hover falls back to the row's current state, so an unset hover token keeps a selected row's colours. */
338
386
  &:hover,
339
387
  &:focus-visible {
340
388
  background-color: var(--select-menu-item-surface-hover, var(--theme-input-surface-hover));
389
+ color: var(--select-menu-item-text-color-hover, var(--_text-color));
390
+ border-color: var(--select-menu-item-border-hover, var(--_border));
341
391
  }
342
392
 
343
393
  &:focus-visible {
@@ -372,6 +422,7 @@ watch(
372
422
 
373
423
  .select-menu-item-label {
374
424
  grid-column: 3;
425
+ grid-row: 1;
375
426
  white-space: nowrap;
376
427
  overflow: hidden;
377
428
  text-overflow: ellipsis;