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
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- This layer includes light/dark/auto colour scheme support (not via CSS `color-scheme` — iPadOS 16 compatibility). By default it:
5
+ This layer includes light/dark/auto colour scheme switching (an attribute, not `light-dark()`, for older iPad Safari). The layer ships light colour values only; dark values are the app's to add (`theming-dark-mode.md`). By default it:
6
6
 
7
7
  1. Injects a synchronous `<head>` script to apply the saved scheme before first paint (FOUC prevention)
8
8
  2. Provides `useColourScheme()` composable to read/write the active scheme via localStorage and `document.documentElement.dataset.colorScheme`
@@ -0,0 +1,214 @@
1
+ # Component compliance checklist
2
+
3
+ The definition of done for every component in this library. It is shared by two commands:
4
+
5
+ - `/new-component` uses it as the build spec for a component that doesn't exist yet.
6
+ - `/migrate-component` uses it to bring an existing component up to standard.
7
+
8
+ Item numbers are stable and are referenced elsewhere ("checklist item 4", the ledger columns, memory
9
+ notes), so add new items at the end rather than renumbering.
10
+
11
+ Go through every item. Skip one only when it genuinely doesn't apply, and say why briefly; don't
12
+ skip silently. Notes marked **Existing component** only apply when migrating.
13
+
14
+ A component passes when its Component Ledger row (`node .claude/component-ledger/build.mjs`, then
15
+ `.claude/component-ledger/audit.json`) shows `score: 5`, a real tier, and `variants`, `legacy_props`,
16
+ `story_args_bug`, `eslint_issues`, `redundant_priv_tokens` and `styling_doc_outdated` all `false`.
17
+ The ledger can't see items 9, 10, 11 and 14, so check those by hand.
18
+
19
+ ## The checklist
20
+
21
+ 1. **Tier folder** — the component lives in one of `01.atoms`–`05.forms` (see
22
+ `project_component_tiers` in memory for what each tier means), in its own kebab-case folder:
23
+ `app/components/<tier>/<family>/`. If none fits cleanly, ask the user rather than guessing.
24
+ **Existing component:** if it lives outside a tier, move it with `git mv`.
25
+ 2. **Props pattern** — `interface Props` + `withDefaults(defineProps<Props>(), {...})`, never
26
+ options-style `defineProps({...})`. Include `styleClassPassthrough` and wire it through
27
+ `useStyleClassPassthrough()`. Consumer-facing data-shape types go in
28
+ `app/types/components/<component-name>.d.ts` and are exported from `app/types/components/index.ts`
29
+ (see `component-export-types.md`), not left inline in the `.vue` file.
30
+ 3. **CSS tokens** — audit every custom property in the component's `<style>` block against the
31
+ rule in CLAUDE.md's "Public token pattern" (consumer relevance, not reuse count):
32
+ - A value a consumer could plausibly want to override — including a per-state variant like a
33
+ hover-only width or a dark-theme colour — must be a **public** token (`--component-name-x`),
34
+ consumed directly at its point of use, even if used only once.
35
+ - A value with no plausible consumer relevance stays **private** (`--_x`), even if used only
36
+ once — don't strip the prefix just because reuse is low.
37
+ - Fix the actual bug class this catches: a consumer-relevant value that's currently private
38
+ with no public fallback. That's the thing to promote, not every `--_` var you find.
39
+ - Remove redundant private tokens (the other half of pitfall #20): a `--_` token declared but
40
+ never read, or one whose whole value is a single `var(--public-token, fallback)` and which is
41
+ read exactly once, is pure duplication. Delete the unread ones; inline the public token at
42
+ the single point of use for the rest. Keep a private token that's read in more than one
43
+ place, is composed (`calc()`, `v-bind()`, several `var()`s), or is re-declared by a
44
+ state/variant selector. `node .claude/component-ledger/fix-private-tokens.mjs <file.vue>`
45
+ lists them (dry run); `--write` applies the ones marked `fix`. A `REVIEW` item is declared
46
+ somewhere other than the root rule or read outside its subtree: check the element that
47
+ reads it sits inside the one that declares it before adding `--include-review`, or fix it by
48
+ hand. Afterwards, update any CONSUMER-STYLING.md or skill doc line that names the removed
49
+ tokens, and delete comments left describing them.
50
+ - Colours default from `--theme-*` tokens through the component's own public tokens, never by
51
+ reading a global `--theme-*` token bare at the point of use (pitfall #14). Pair a dark/bold
52
+ surface with `--theme-on-surface`, not `--theme-text` (pitfall #13). For a card or panel
53
+ background default to `--theme-surface-subtle` (the surface `--theme-text` sits on);
54
+ `--theme-surface` and `--theme-border` are bold steps meant for buttons and focus, and make a
55
+ card a solid block of theme colour.
56
+ - Style rules sit in `@layer components`, with child selectors nested inside the root block
57
+ using native nesting (`& .block__child`), never Sass `&__child` concatenation
58
+ (`css-nesting-conventions.md`).
59
+ 4. **CONSUMER-STYLING.md** — create or update so every public token the component exposes is
60
+ documented, with its default. Skip only if the component genuinely has no override surface at
61
+ all (no CSS custom properties, no class passthrough). Use this fixed layout, in this order:
62
+ - `## Public token API` — token tables (split into `###` groups by element when there are
63
+ several), each token with its default; list any private `--_` tokens as "not public API", or
64
+ say there are none.
65
+ - `## State hooks` — `data-*` attributes / state selectors and the inner element class names.
66
+ Omit only if the component has neither.
67
+ - Component-specific reference sections (motion, sizing model, etc.) may follow here.
68
+ - `## Global theming` — optional. Include a `:where(html) { ... }` block only when there's
69
+ something worth showing (e.g. a common site-wide tweak); don't pad it out.
70
+ - `## Local overrides` — required heading (the ledger's `styling_doc_outdated` check looks for
71
+ it). The mechanics are the same for every component and live in
72
+ `.claude/skills/component-local-style-override.md`, so don't write bespoke examples by
73
+ default. Write one standard paragraph (copy it from any migrated doc, e.g.
74
+ `05.forms/form-field/CONSUMER-STYLING.md`): set tokens on an element you own (page/section
75
+ class, or a `:style-class-passthrough` class, or a plain `class` that falls through when there's
76
+ no passthrough prop); keep the block unlayered; from a **consumer's** `<style scoped>` file,
77
+ tokens on their own element still work but selectors reaching inside need `:deep()` (this
78
+ library's own styles are never scoped); link the guide. Then add a **Caveat** only where this
79
+ component breaks that model, most often a token it re-declares on its own elements (so an
80
+ ancestor value never lands, e.g. `DisplayThemeSwitch`'s sizing tokens, `ServiceSummary`'s
81
+ `--display-pill-*`, `InputError`'s `data-theme`), or a wrapper that doesn't forward
82
+ passthrough. Check with a grep for `^\s*--<name>-[\w-]+\s*:` in the component's `<style>`.
83
+ Existing examples can stay as `###` subsections (`Page or section`, `One instance`).
84
+ - `## Recipe: <name>` — optional, for component-specific patterns that need several tokens
85
+ working together (e.g. InputDescription's panel/callout).
86
+ - `## Class passthrough` — what `style-class-passthrough` targets and whether it's actually
87
+ reachable in normal use.
88
+ - `## Notes` — optional, last.
89
+
90
+ Dated "Changed YYYY-MM-DD" migration notes go as blockquotes inside the relevant section.
91
+ 5. **Tests** — create or bring up to date in `tests/`, following the Testing Requirements section
92
+ of CLAUDE.md (mountSuspended, fake-timer rules, etc.). Skip only for a trivial presentational
93
+ component with no logic worth testing — say so explicitly if you skip. If anything is derived
94
+ from slots, test it by toggling a `v-if`'d slot in a host component after mount (pitfall #25).
95
+ 6. **Storybook story** — create or update `stories/*.stories.ts` with controls for props/slots
96
+ that warrant them (see `.claude/skills/storybook-add-story.md`). The story title uses the tier
97
+ name directly. A component whose layout responds to its container gets the `CanvasSwitcher`
98
+ decorator (see `PageRow.stories.ts` or `GoogleReviews.stories.ts`, which also shows a per-story
99
+ starting canvas via `parameters.initialCanvas`) rather than a hand-sized wrapper or a width arg.
100
+ 6a. **Storybook Controls-panel reactivity** — check every story `Template`/`render` function in
101
+ the component's `stories/*.stories.ts` files for this bug: `@storybook/vue3` mounts the story
102
+ component **once** and, on every Controls-panel change, mutates the same reactive `args`
103
+ object in place — it never re-runs `setup()`. A story that destructures or spreads `args` into
104
+ local variables/a ref at setup-time (the tell-tale shape: `const { modelValue, ...otherArgs }
105
+ = args;`, whether or not it's then fed into a `ref()`) takes a one-time snapshot, so most
106
+ Controls silently stop updating the rendered story after first render — often alongside a
107
+ prop-name typo in `argTypes` that compounds it (e.g. a control literally named differently
108
+ from the real component prop it's meant to drive — check that too). Fix by binding the
109
+ template directly to the live object (`v-model="args.modelValue"`, `v-bind="componentArgs"`
110
+ where `componentArgs` is a `computed()` that strips only genuinely non-prop extra args) rather
111
+ than copying values out in `setup()` — a destructure/spread from `args` is only safe when it's
112
+ inside a `computed()`. The ledger's `story_args_bug` column flags this automatically (any
113
+ `{...} = args;` spread not wrapped in a `computed()`); still eyeball each story file yourself,
114
+ since a differently-shaped variant of the same mistake may not match that exact heuristic.
115
+ 6b. **Stress-test story** — add a `StressTest` story ("Stress Test (Worst-Case Data)") that
116
+ feeds the component the most hostile data a QA tester could think of, and fix whatever it
117
+ breaks. Go through every prop, slot and data field and ask what would break it, for example:
118
+ - very long text, and long **unbroken** strings (no spaces, a long URL) in every text field,
119
+ including labels and copy props (long translated copy, e.g. German)
120
+ - empty strings, missing optional fields, a single character, a single item, the maximum
121
+ number of items
122
+ - emoji (including at the start of anything that takes initials or first letters), HTML-like
123
+ text (must render as text), right-to-left text
124
+ - out-of-range or odd numbers (0, negative, above the maximum, fractional, very large)
125
+ - broken image or link URLs
126
+ Describe what to check in the story's docs description, and look at it at every
127
+ `CanvasSwitcher` width. Fixes usually mean `overflow-wrap: anywhere`, `min-inline-size: 0` on
128
+ flex/grid children, `Array.from(str)` instead of `str[0]`, and `clamp()` on values
129
+ derived from data. Give each text element a consumer might want to shorten (names, titles,
130
+ descriptions, body text) a line-clamp token, `--<component>-<element>-line-clamp`, which
131
+ covers ellipsis too (`1` is single-line ellipsis). Default it to `none` unless the layout
132
+ clearly needs a cap, and never clamp text that must stay readable (legal or attribution text,
133
+ link text that names its destination). Shape and rules: `theming-component-token-pattern.md`,
134
+ "Line-clamp tokens". Add a unit test for any logic fix. A component that takes no data or copy
135
+ (e.g. a pure layout wrapper) still gets one with oversized slot content.
136
+ 7. **Skill doc** — create or update `.claude/skills/components/<component-name>.md`, following the
137
+ pattern of an existing one. A labelled `<Name>Field` wrapper (or any other wrapper) is documented
138
+ in its control's doc under a "Variants" section, not in a doc of its own
139
+ (`feedback_variants_deprecated_as_own_skill_docs`). **Existing component:** if you find a
140
+ `variants/` subfolder, flatten it into the parent folder (`git mv` the `.vue` files plus their
141
+ stories/tests up one level, fix relative imports); `pathPrefix: false` keeps the auto-import
142
+ names unchanged. All existing `variants/` folders were flattened 2026-09-25.
143
+ 8. **VS Code snippet** — create or update `.vscode/srcdev-component-{family}.code-snippets`,
144
+ covering the control and its Field wrapper in one file.
145
+ 9. **Accessibility** — check for gaps beyond what already-passing tests would catch:
146
+ - Interactive controls (buttons, toggles, custom form-like widgets) have an accessible name —
147
+ visible text, `aria-label`, or `aria-labelledby`.
148
+ - Keyboard behaviour matches what's actually implemented — no keydown case that only calls
149
+ `event.preventDefault()` with a comment like "could add this later", and no screen-reader
150
+ copy (visible or `sr-only`) that describes an interaction the component doesn't actually
151
+ perform. Cross-check every claim in the copy against the handler that's supposed to back it.
152
+ - Focus is visible (`:focus-visible`, not a suppressed outline) on anything focusable.
153
+ - Live-updating or auto-advancing content (carousels, marquees, auto-dismissing toasts) has a
154
+ way to pause it, per WCAG 2.2.2 — a visible control, or pause-on-hover/focus at minimum.
155
+ - `prefers-reduced-motion` is respected for any animation that isn't purely decorative.
156
+ - A component with a `tag` prop that can render a landmark uses `useAriaLabelledById`
157
+ (`component-aria-landmark.md`), unless it falls back to `aria-label` when no heading is
158
+ given: the composable can't do that and warns whenever a landmark has no heading. Then label
159
+ it directly with `useId()` (`aria-labelledby` when the heading slot is filled, `aria-label`
160
+ otherwise, both decided by a function called from the template, per pitfall #25), as
161
+ `GoogleReviews` does.
162
+ 10. **Localisation (no hardcoded consumer-facing text)** — grep the template and script for
163
+ user-visible string literals (button copy, `aria-label`/`aria-description` text, placeholder
164
+ text, empty-state messages, etc.) that aren't already props. This library has no i18n
165
+ framework dependency (see `MarqueeScroller`'s `playLabel`/`pauseLabel`/`ariaLabel`/
166
+ `ariaDescription` for the pattern) — promote any hardcoded copy to a string prop with the
167
+ existing English text as its default, so a consumer can pass translated strings from their own
168
+ i18n solution. Icon-only controls should also get an icon-override prop or slot (see
169
+ `playIcon`/`pauseIcon`/`toggle-icon`) alongside the label prop, not just the label. Default
170
+ copy follows the em dash ban for visitor-facing text.
171
+ 11. **No `light-dark()` CSS function in the component's own values** — grep the `<style>` block
172
+ (and any `v-bind()`-fed script constants) for `light-dark(`. Older iPads/Safari versions don't
173
+ support it, so this library's own default values must never rely on it — use the existing
174
+ `--theme-*` token convention (pitfall #14) where one already fits. Where none fits and you're
175
+ replacing a literal `light-dark(light-value, dark-value)` call directly, keep the **light**
176
+ value only — don't invent a dark-mode fallback scheme, and don't ask which of the two to keep.
177
+ The mechanism stays available for consumers: a consumer app is free to define its own public
178
+ token overrides using `light-dark()` inside its own CSS, since that's their own browser-support
179
+ decision to make, not this library's default. The global token layer (`03.theming`, `a11y/_variables.css`) is light-only too since 2026-10-04, so `--theme-*` tokens are safe to consume.
180
+ 12. **Add the component to the Migrated Fields Form story** — once the component reaches 5/5,
181
+ add one field for it to
182
+ `app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts` (import the
183
+ component, add a field to the demo form, wire minimal state/error handling matching the
184
+ existing fields). This story is a living migration-progress tracker, not just a demo — its own
185
+ top-of-file comment lists which components are currently included and must be updated too
186
+ (add the new component to the list, bump the "As of `<date>`" note). Skip only if the
187
+ component's shape genuinely doesn't fit a form field (anything outside `05.forms`, or a
188
+ non-input `05.forms` helper component) — state why if you skip.
189
+ 13. **Naming** — bare control `<Name>`, labelled wrapper `<Name>Field`, root DOM class following
190
+ the name (`.input-number`, `.input-number-field`), per `.claude/skills/component-naming.md`.
191
+ Multi-word, never a native or plausibly-future HTML element name, and not a bare common word
192
+ a consuming app is likely to define itself (the Nuxt-layer collision caveat).
193
+ **Existing component:** if it (or its wrappers) still uses a `Core`, `Default` or `WithLabel`
194
+ suffix, rename it. The doc's backlog table gives the proposed name and which consumer repos
195
+ use the old one. Rows marked **decide** (collision-prone generic names like `Card`/`Tabs`)
196
+ need an `AskUserQuestion` before renaming. Follow the doc's rename procedure (including
197
+ updating consumer repos) and tick the row off. Do this as the last checklist step, so the
198
+ earlier steps' edits don't have to track a mid-flight rename.
199
+ 14. **Known pitfalls** — check the component against the CLAUDE.md pitfalls that recur in new
200
+ code (the ledger can't see any of these):
201
+ - Every structural class name is prefixed with the component name (`.display-dialog-header`,
202
+ not `.header`), since the component renders inside the consumer's DOM (pitfall #16).
203
+ - A `position: fixed`/`sticky` overlay exposes a public z-index token defaulting to `999999`
204
+ (pitfall #17).
205
+ - No utility class from `06.utility-classes` in the component's own markup unless it's used
206
+ for what that class actually means (pitfall #18, `mi-12`).
207
+ - Nothing load-bearing relies on a CSS feature WebKit lacks (pitfall #19).
208
+ - No `computed()` over `useSlots()`; use a function called from the template (pitfall #25).
209
+ - Values derived from props are `computed()`, not plain consts frozen at mount.
210
+ - Explicit closing tags (no self-closing non-void elements), hyphenated props in templates, no
211
+ manual Vue imports in components.
212
+ - `NuxtImg` gets explicit `width` and `height`; a `<video>` uses a `<source :src>` child with
213
+ `:key` and `.load()` (pitfall #12).
214
+ - No `#components` import (breaks Storybook); use `resolveComponent()`.
@@ -62,8 +62,8 @@ Target `.my-action-button.input-button` inside the wrapper to override InputButt
62
62
 
63
63
  /* Use private tokens only for new semantic states with no theme equivalent */
64
64
  &.some-state {
65
- --_state-surface: light-dark(var(--green-01), var(--green-09));
66
- --_state-text: light-dark(var(--green-08), var(--green-01));
65
+ --_state-surface: var(--green-01);
66
+ --_state-text: var(--green-08);
67
67
 
68
68
  .my-action-button.input-button {
69
69
  background-color: var(--_state-surface);
@@ -90,10 +90,10 @@ See [CONSUMER-STYLING.md](../../app/components/03.organisms/image-galleries/caro
90
90
  | `--carousel-flip-item-max-width` | `80rem` | Max width of a single item, before subtracting the edge-preview width |
91
91
  | `--carousel-flip-edge-preview-width` | `4rem` | How much of the neighbouring items peek in at each edge |
92
92
  | `--carousel-flip-marker-gap` | `1rem` | Gap between marker (dot) buttons |
93
- | `--carousel-flip-marker-active-colour` | `light-dark(var(--slate-10), var(--slate-00))` | Active marker background colour |
94
- | `--carousel-flip-button-background-colour` | `light-dark(white, var(--slate-08))` | Prev/next button background colour |
93
+ | `--carousel-flip-marker-active-colour` | `var(--slate-10)` | Active marker background colour |
94
+ | `--carousel-flip-button-background-colour` | `white` | Prev/next button background colour |
95
95
  | `--carousel-flip-button-border-width` | `0.1rem` | Prev/next button border width |
96
- | `--carousel-flip-button-border-colour` | `light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%))` | Prev/next button border colour |
96
+ | `--carousel-flip-button-border-colour` | `hsl(0, 29%, 3%)` | Prev/next button border colour |
97
97
  | `--carousel-flip-button-border-radius` | `100vw` | Prev/next button corner radius |
98
98
  | `--carousel-flip-button-padding` | `0.8rem` | Prev/next button padding |
99
99
  | `--carousel-flip-button-icon-size` | `2.4rem` | Prev/next button icon glyph size |
@@ -46,8 +46,8 @@ see CONSUMER-STYLING.md for why these can't be resized via CSS.
46
46
 
47
47
  | Property | Default | Controls |
48
48
  |----------|---------|----------|
49
- | `--clipped-panel-background-colour` | `light-dark(hsl(0, 0%, 96%), hsl(0, 0%, 12%))` | Panel background |
50
- | `--clipped-panel-outline-colour` | `light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%))` | Outline colour and text/icon colour |
49
+ | `--clipped-panel-background-colour` | `hsl(0, 0%, 96%)` | Panel background |
50
+ | `--clipped-panel-outline-colour` | `hsl(0, 29%, 3%)` | Outline colour and text/icon colour |
51
51
  | `--clipped-panel-outline-width` | `1px` | Outline width |
52
52
 
53
53
  ## Notes
@@ -59,10 +59,10 @@ See [CONSUMER-STYLING.md](../../app/components/01.atoms/content-wrappers/layout-
59
59
  | `--dashboard-quad-grid-gap` | `2rem` | Gap between the four slot panels |
60
60
  | `--dashboard-quad-grid-padding` | `2rem` | Padding inside each slot panel |
61
61
  | `--dashboard-quad-grid-outline-width` | `0.1rem` | Slot panel outline width |
62
- | `--dashboard-quad-grid-outline-colour` | `light-dark(black, white)` | Slot panel outline colour |
62
+ | `--dashboard-quad-grid-outline-colour` | `black` | Slot panel outline colour |
63
63
  | `--dashboard-quad-grid-outline-offset` | `0` | Slot panel outline offset |
64
64
  | `--dashboard-quad-grid-border-width` | `0` | Slot panel border width (off by default — the panel edge is drawn by the outline) |
65
- | `--dashboard-quad-grid-border-colour` | `light-dark(black, white)` | Slot panel border colour, only visible once `--dashboard-quad-grid-border-width` is set |
65
+ | `--dashboard-quad-grid-border-colour` | `black` | Slot panel border colour, only visible once `--dashboard-quad-grid-border-width` is set |
66
66
  | `--dashboard-quad-grid-border-radius` | `0.5rem` | Slot panel corner radius |
67
67
  | `--dashboard-quad-grid-rule-width` | `0.1rem` | Width of the grid gap rule line (CSS Gap Decorations, limited browser support) |
68
68
  | `--dashboard-quad-grid-rule-colour` | `transparent` | Colour of the grid gap rule line, invisible by default |
@@ -67,7 +67,7 @@ See [CONSUMER-STYLING.md](../../app/components/01.atoms/content-wrappers/layout-
67
67
  | `--dashboard-stats-grid-gap` | `1.2rem` | Gap between panels, in every row |
68
68
  | `--dashboard-stats-grid-panel-padding` | `1.2rem` | Padding inside each panel |
69
69
  | `--dashboard-stats-grid-border-width` | `0.1rem` | Panel border width |
70
- | `--dashboard-stats-grid-border-colour` | `light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%))` | Panel border colour |
70
+ | `--dashboard-stats-grid-border-colour` | `hsl(0, 29%, 3%)` | Panel border colour |
71
71
  | `--dashboard-stats-grid-border-radius` | `1.2rem` | Panel corner radius |
72
72
  | `--dashboard-stats-grid-rule-width` | `0.1rem` | Width of the grid gap rule line (CSS Gap Decorations, limited browser support) |
73
73
  | `--dashboard-stats-grid-rule-colour` | `transparent` | Colour of the grid gap rule line, invisible by default |
@@ -59,7 +59,7 @@ const navLinks: ResponsiveHeaderNavItem[] = [
59
59
  | `--deep-expanding-menu-classic-item-gap` | `2.4rem` | Gap between top-level items |
60
60
  | `--deep-expanding-menu-classic-link-border-width` | `0.2rem` | Bottom border width on links/toggles |
61
61
  | `--deep-expanding-menu-classic-link-padding-block` | `0.8rem` | Vertical padding on links/toggles |
62
- | `--deep-expanding-menu-classic-link-border-colour-hover` | `light-dark(var(--blue-10), var(--slate-00))` | Bottom border colour on hover/focus |
62
+ | `--deep-expanding-menu-classic-link-border-colour-hover` | `var(--blue-10)` | Bottom border colour on hover/focus |
63
63
  | `--deep-expanding-menu-classic-icon-size` | `1.2rem` | Caret icon size |
64
64
  | `--deep-expanding-menu-classic-panel-offset-top` | `4rem` | Panel offset from the toggle |
65
65
  | `--deep-expanding-menu-classic-panel-width` | `20rem` | Panel width below `768px` |
@@ -61,7 +61,7 @@ const navLinks: ResponsiveHeaderNavItem[] = [
61
61
  | `--deep-expanding-menu-gap` | `2.4rem` | Gap between top-level items |
62
62
  | `--deep-expanding-menu-link-border-width` | `0.2rem` | Bottom border width on links/toggles |
63
63
  | `--deep-expanding-menu-link-padding-block` | `0.8rem` | Vertical padding on links/toggles |
64
- | `--deep-expanding-menu-link-border-colour-hover` | `light-dark(var(--blue-10), var(--slate-00))` | Bottom border colour on hover/focus |
64
+ | `--deep-expanding-menu-link-border-colour-hover` | `var(--blue-10)` | Bottom border colour on hover/focus |
65
65
  | `--deep-expanding-menu-icon-size` | `1.2rem` | Caret icon size |
66
66
  | `--deep-expanding-menu-panel-width` | `min(100%, 50vw)` | Popover panel width |
67
67
  | `--deep-expanding-menu-panel-background-colour` | `white` | Popover panel background |
@@ -0,0 +1,193 @@
1
+ ---
2
+ name: GoogleReviews
3
+ description: GoogleReviews + GoogleReviewCard — Google Places reviews in a single scrolling row; server route, env vars, useGoogleReviews composable, props, slots, tokens, Google terms
4
+ type: reference
5
+ ---
6
+
7
+ # GoogleReviews
8
+
9
+ ## Overview
10
+
11
+ `GoogleReviews` (`03.organisms/google-reviews`) shows a place's Google reviews as a single
12
+ horizontally scrolling row of cards, with an overall rating summary, prev/next buttons and the
13
+ Google Maps attribution. It retrieves nothing itself: the layer's server route fetches the data
14
+ with a server-only API key, `useGoogleReviews()` reads that route, and the component displays
15
+ what it's given.
16
+
17
+ Google returns **at most 5 reviews** per place. Showing more needs the Business Profile API with
18
+ the owner signed in, which this doesn't support.
19
+
20
+ ## Setup (consumer app)
21
+
22
+ 1. In Google Cloud, enable **Places API (New)**, create an API key and restrict it to that API.
23
+ The `reviews` field is billed at the Place Details Enterprise + Atmosphere rate; the route
24
+ caches responses to limit calls.
25
+ 2. Set env vars (server-only, never sent to the browser):
26
+
27
+ | Env var | Purpose |
28
+ |---|---|
29
+ | `NUXT_GOOGLE_REVIEWS_API_KEY` | Places API key (required) |
30
+ | `NUXT_GOOGLE_REVIEWS_PLACE_ID` | The business's place ID (required; find it with Google's Place ID Finder) |
31
+ | `NUXT_GOOGLE_REVIEWS_LANGUAGE_CODE` | Optional, e.g. `en-GB` |
32
+ | `NUXT_GOOGLE_REVIEWS_CACHE_MAX_AGE` | Seconds to cache the response, default `3600` |
33
+
34
+ 3. Use it:
35
+
36
+ ```vue
37
+ <script setup lang="ts">
38
+ const { data } = await useGoogleReviews();
39
+ </script>
40
+
41
+ <template>
42
+ <GoogleReviews :data="data" />
43
+ </template>
44
+ ```
45
+
46
+ The place ID is read only from server config, never from the request, so the route can't be used
47
+ to look up other places on your key. The layer adds `lh3.googleusercontent.com` (reviewer photos)
48
+ to `image.domains`.
49
+
50
+ ## Server route and composable
51
+
52
+ - `GET /api/google-reviews` (`server/api/google-reviews.get.ts`) returns `GoogleReviewsData`,
53
+ cached with Nitro's `defineCachedEventHandler` for `cacheMaxAge` seconds (stale-while-revalidate).
54
+ - Missing key or place ID: `500` whose message names the missing env var. Google request fails:
55
+ `502`.
56
+ - `useGoogleReviews()` is `useFetch<GoogleReviewsData>("/api/google-reviews")`, so it returns
57
+ `{ data, status, error, refresh }`. Use `status`/`error` for your own fallback; the component
58
+ renders nothing without data.
59
+
60
+ ```ts
61
+ import type { GoogleReviewsData, GoogleReview } from "srcdev-nuxt-components";
62
+
63
+ interface GoogleReview {
64
+ authorName: string;
65
+ authorUri?: string;
66
+ authorPhotoUri?: string;
67
+ rating: number;
68
+ text: string;
69
+ relativeTime: string; // "2 weeks ago", in the configured language
70
+ publishTime: string; // ISO timestamp
71
+ reviewUri?: string;
72
+ }
73
+
74
+ interface GoogleReviewsData {
75
+ placeName: string;
76
+ rating: number;
77
+ totalReviews: number;
78
+ mapsUri: string;
79
+ reviews: GoogleReview[];
80
+ }
81
+ ```
82
+
83
+ ## Props
84
+
85
+ | Prop | Type | Default | Notes |
86
+ |---|---|---|---|
87
+ | `data` | `GoogleReviewsData \| null` | required | Usually `data` from `useGoogleReviews()` |
88
+ | `minRating` | `number` (0–5) | `0` | Hides lower-rated reviews and shows `filterNotice` |
89
+ | `showSummary` | `boolean` | `true` | Overall rating, stars and total count linked to the Maps listing |
90
+ | `tag` | `"section" \| "div"` | `"section"` | Root element |
91
+ | `ariaLabel` | `string` | `"Google reviews"` | Root label when there's no `heading` slot |
92
+ | `prevLabel` / `nextLabel` | `string` | `"Previous reviews"` / `"Next reviews"` | Button accessible names |
93
+ | `prevIcon` / `nextIcon` | `string` | `ic:outline-keyboard-arrow-left/right` | Button icons |
94
+ | `ratingLabel` | `string` | `"Rated {rating} out of 5"` | Screen-reader rating text |
95
+ | `totalLabel` | `string` | `"{count} reviews on Google"` | Summary link text |
96
+ | `readMoreLabel` | `string` | `"Read on Google"` | Per-card link text |
97
+ | `filterNotice` | `string` | `"Showing reviews rated {rating} stars and above, in Google's order."` | Shown when `minRating > 0` |
98
+ | `attributionText` | `string` | `"Reviews from Google Maps"` | Keep "Google Maps" when translating |
99
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | Classes on the root |
100
+
101
+ All copy props are there for localisation; pass translated strings. `{rating}` and `{count}` are
102
+ replaced.
103
+
104
+ ## Slots
105
+
106
+ | Slot | Props | Notes |
107
+ |---|---|---|
108
+ | `heading` | `{ headingId }` | Heading above the summary. Bind `headingId` to it: the root is then labelled by the heading instead of `ariaLabel` |
109
+ | `card` | `{ review }` | Replaces each card. The list, scrolling and attribution stay. Keep the author attribution (name, photo, profile link) if you replace it |
110
+
111
+ ## Behaviour
112
+
113
+ - Renders nothing when `data` is null or no reviews are left after filtering.
114
+ - The summary shows the overall rating (one decimal), stars, and the total count linked to the
115
+ Google Maps listing.
116
+ - Each card shows the reviewer's avatar (photo, or initials), name linked to their Google profile,
117
+ star rating, relative date, and the review text clamped to 5 lines, with a "Read on Google" link
118
+ to the full review.
119
+ - The Google Maps attribution is always shown.
120
+ - Cards sit in a single row that never wraps and scrolls horizontally with snapping, by touch,
121
+ trackpad or keyboard (the row is focusable; arrow keys scroll it, and tabbing to a link scrolls
122
+ its card into view).
123
+ - Prev/next scroll by one card, are disabled (`aria-disabled`, still focusable) at either end, and
124
+ are hidden when every card fits. Direction follows `dir="rtl"`.
125
+ - Card sections line up across the row (name, date, stars, text and link each share a row, via
126
+ subgrid), even when one reviewer's name or date wraps further than the others.
127
+ - Smooth scrolling is turned off under `prefers-reduced-motion`.
128
+ - The root is a labelled region; reviews are a `ul`/`li` list. Stars are hidden from screen
129
+ readers and replaced by `ratingLabel` text; the avatar is hidden too, since the name is read out.
130
+ Each "Read on Google" link is described by its author's name.
131
+ - `minRating` hides lower-rated reviews and shows the filter notice; no notice without a filter.
132
+ - Survives hostile data (see the `StressTest` story): long and unbroken text wraps inside its card,
133
+ reviewer names are cut to 2 lines (`--google-reviews-author-line-clamp`),
134
+ initials take whole characters (emoji-safe), star fill is clamped to 0–5, review text renders as
135
+ plain text. A broken photo URL still shows the browser's broken-image icon, since
136
+ `DisplayAvatar` has no image-error fallback.
137
+
138
+ ## Variants
139
+
140
+ ### GoogleReviewCard
141
+
142
+ One review, used by `GoogleReviews` and usable on its own (e.g. in a custom layout via the `card`
143
+ slot).
144
+
145
+ | Prop | Type | Default |
146
+ |---|---|---|
147
+ | `review` | `GoogleReview` | required |
148
+ | `tag` | `"article" \| "div"` | `"article"` |
149
+ | `ratingLabel` | `string` | `"Rated {rating} out of 5"` |
150
+ | `readMoreLabel` | `string` | `"Read on Google"` |
151
+ | `styleClassPassthrough` | `string \| string[]` | `[]` |
152
+
153
+ ```vue
154
+ <GoogleReviews :data="data">
155
+ <template #card="{ review }">
156
+ <GoogleReviewCard :review="review" tag="div" :style-class-passthrough="['my-review']" />
157
+ </template>
158
+ </GoogleReviews>
159
+ ```
160
+
161
+ ## CSS custom properties
162
+
163
+ All `--google-reviews-*`, shared by both components. Full list with defaults in
164
+ `app/components/03.organisms/google-reviews/CONSUMER-STYLING.md`. Most used:
165
+
166
+ ```css
167
+ .reviews-section {
168
+ --google-reviews-card-width: min(28rem, 80%);
169
+ --google-reviews-card-surface: var(--slate-01);
170
+ --google-reviews-card-border-radius: 1.6rem;
171
+ --google-reviews-star-colour: var(--amber-06);
172
+ --google-reviews-text-line-clamp: 4;
173
+ --google-reviews-author-line-clamp: 1;
174
+ --google-reviews-scroll-padding: 1.6rem;
175
+ }
176
+ ```
177
+
178
+ ## Google's terms
179
+
180
+ - Credit each review's author: the name, photo and profile link are rendered by default; keep them
181
+ if you use the `card` slot.
182
+ - Keep the Google Maps attribution visible and readable.
183
+ - Any filtering needs a visible notice describing it (`filterNotice`, shown automatically with
184
+ `minRating`).
185
+ - Don't store review content long-term. The route's cache is short-lived; check Google's current
186
+ Places API policies before raising `NUXT_GOOGLE_REVIEWS_CACHE_MAX_AGE` a lot.
187
+
188
+ ## Notes
189
+
190
+ - Out of scope: more than 5 reviews, multiple places per site, sorting other than Google's
191
+ relevance order, auto-advance, review structured data (Google doesn't give review rich results
192
+ for a business's own reviews).
193
+ - Storybook can't reach the server route; stories use mock `GoogleReviewsData`.
@@ -156,6 +156,10 @@ to mirror `InputTextCore` directly:
156
156
  `var(--input-number-surface-hover, var(--theme-input-surface-hover))`) rather than kept as
157
157
  private wrappers. `--_border-focus` stayed private since it's now genuinely reused (the
158
158
  wrapper's own focus outline *and* the embedded button's focus outline both read it).
159
+ - Fixed 2026-10-04: the wrapper's hover and focus outlines only matched `input:hover` /
160
+ `input:focus-visible`, so hovering or tabbing to a step button showed no outer ring, unlike
161
+ `InputTextCore` (and so `InputTextAsNumberWithLabel`), which matches `button` too. Both
162
+ selectors are now `:has(input:…, button:…)`, mirroring `InputTextCore`.
159
163
 
160
164
  **Separately found in the same investigation** (not an `InputNumber` bug, but what the visual
161
165
  comparison was originally checking against): `InputTextAsNumberWithLabel.vue` had its own,
@@ -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.