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
@@ -1,11 +1,15 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
- import { reactive } from "vue";
2
+ import { reactive, ref } from "vue";
3
3
  import InputTextWithLabel from "../../input-text/InputTextWithLabel.vue";
4
+ import InputPasswordWithLabel from "../../input-text/InputPasswordWithLabel.vue";
5
+ import InputTextAsNumberWithLabel from "../../input-text/InputTextAsNumberWithLabel.vue";
4
6
  import InputRangeDefault from "../../input-range/InputRangeDefault.vue";
5
7
  import InputNumberField from "../../input-number/InputNumberField.vue";
8
+ import InputOtpField from "../../input-otp/InputOtpField.vue";
6
9
  import InputTextareaWithLabel from "../../input-textarea/InputTextareaWithLabel.vue";
7
10
  import InputSelectWithLabel from "../../input-select/InputSelectWithLabel.vue";
8
11
  import ToggleSwitchWithLabel from "../../toggle-switch/ToggleSwitchWithLabel.vue";
12
+ import ToggleSwitchWithLabelInline from "../../toggle-switch/ToggleSwitchWithLabelInline.vue";
9
13
  import MultipleCheckboxes from "../../input-checkbox/MultipleCheckboxes.vue";
10
14
  import SingleCheckbox from "../../input-checkbox/SingleCheckbox.vue";
11
15
  import MultipleRadiobuttons from "../../input-radio/MultipleRadiobuttons.vue";
@@ -14,35 +18,31 @@ import InputButton from "../../input-button/InputButton.vue";
14
18
  import FormField from "../../form-field/FormField.vue";
15
19
  import FormWrapper from "../../form-wrapper/FormWrapper.vue";
16
20
  import HeroText from "../../../01.atoms/text-blocks/hero-text/HeroText.vue";
17
- import type { InputUiVariant, IFormMultipleOptions } from "~/types/forms/types.forms.d";
21
+ import type { InputUiVariant, IFormMultipleOptions, InputTypesText, InputMode } from "~/types/forms/types.forms.d";
18
22
 
19
23
  interface MigratedFieldsFormStoryArgs {
20
24
  inputVariant: InputUiVariant;
21
25
  }
22
26
 
23
- // Living reference, not a component of its own — one field per 05.forms component group that
24
- // currently scores 5/5 in the Component Ledger (.claude/component-ledger/audit.json: tier folder,
25
- // tests, story, skill doc, CONSUMER-STYLING.md, VS Code snippet). As of 2026-09-27 that's
26
- // InputTextCore (via InputTextWithLabel), InputRangeCore (via InputRangeDefault), InputNumber
27
- // (via InputNumberField), InputTextareaCore (via InputTextareaWithLabel), InputSelectCore
28
- // (via InputSelectWithLabel), ToggleSwitchCore (via ToggleSwitchWithLabel), and input-checkbox
29
- // (MultipleCheckboxes + SingleCheckbox), plus input-radio (MultipleRadiobuttons, 5/5 as of
30
- // 2026-09-27). FormField (5/5 as of 2026-09-26) is the layout wrapper
31
- // around every field below, and FormFieldset (5/5 as of 2026-09-26) is rendered inside the
32
- // checkbox fields, FormWrapper (5/5 as of 2026-09-26) wraps the form itself, and InputError
33
- // (5/5 as of 2026-09-26) is the error strip every field below renders, so none of the four
34
- // gets a field of its own. InputDescription (5/5 as of 2026-09-26) is the help text under the
35
- // "Full name" field, rendered through InputTextWithLabel's descriptionText slot. InputLabel (5/5 as
36
- // of 2026-09-26) is the <label> every labelled field below renders, so it has no field of its own
37
- // either. The input-checkbox-radio family (InputCheckboxRadio/Field/Button, 5/5 as of 2026-09-27)
38
- // renders every checkbox and radio below, so it has no field of its own. TripleToggleSwitch (5/5 as of
39
- // 2026-09-27) is the "Theme preference" field; it has no Field wrapper or error state of its own,
40
- // so it sits under a plain caption and always has a value. Every other 05.forms component
41
- // (radio, ...) is still mid-migration.
42
- // Add a field here each time /migrate-component brings another 05.forms component up to 5/5, so
43
- // this story doubles as a visible migration-progress tracker rather than living only in the
44
- // ledger's HTML output. No validation wiring (useZodValidation/zod) here — that's the consuming
45
- // app's concern, this is a visual/composition reference only.
27
+ // Living reference, not a component of its own: every 05.forms component that scores 5/5 in the
28
+ // Component Ledger (.claude/component-ledger/audit.json) appears here, either as its own field or
29
+ // rendered inside one. As of 2026-10-04 every 05.forms component is 5/5, so all of them are in:
30
+ // - Own field: InputTextWithLabel (InputTextCore; plus one per type/inputmode pair for checking
31
+ // on-device keyboards), InputPasswordWithLabel,
32
+ // InputTextAsNumberWithLabel, InputRangeDefault (InputRangeCore), InputNumberField
33
+ // (InputNumber), InputOtpField (InputOtp), InputTextareaWithLabel (InputTextareaCore), InputSelectWithLabel
34
+ // (InputSelectCore), ToggleSwitchWithLabel and ToggleSwitchWithLabelInline (ToggleSwitchCore),
35
+ // MultipleCheckboxes, SingleCheckbox, MultipleRadiobuttons, TripleToggleSwitch ("Theme
36
+ // preference": no Field wrapper or error state, so it sits under a plain caption).
37
+ // - Rendered inside other fields: FormWrapper (wraps the form), FormField (wraps every field),
38
+ // FormFieldset (checkbox/radio groups), InputLabel, InputDescription (the "Full name" and
39
+ // "Password" help text), InputError (every error strip), the input-checkbox-radio family
40
+ // (every checkbox and radio), InputButton (Continue/Clear errors, and the inline buttons inside
41
+ // the password and number fields), PendingEffect (Continue's simulated 1.5s submit).
42
+ // Add a field here each time a new 05.forms component reaches 5/5, so this story stays a visible
43
+ // completeness check rather than living only in the ledger's HTML output. No validation wiring
44
+ // (useZodValidation/zod) here — that's the consuming app's concern, this is a visual/composition
45
+ // reference only.
46
46
  export default {
47
47
  title: "Patterns/Migrated Fields Form",
48
48
  argTypes: {
@@ -61,11 +61,15 @@ export default {
61
61
  const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
62
62
  components: {
63
63
  InputTextWithLabel,
64
+ InputPasswordWithLabel,
65
+ InputTextAsNumberWithLabel,
64
66
  InputRangeDefault,
65
67
  InputNumberField,
68
+ InputOtpField,
66
69
  InputTextareaWithLabel,
67
70
  InputSelectWithLabel,
68
71
  ToggleSwitchWithLabel,
72
+ ToggleSwitchWithLabelInline,
69
73
  MultipleCheckboxes,
70
74
  SingleCheckbox,
71
75
  MultipleRadiobuttons,
@@ -78,17 +82,36 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
78
82
  setup() {
79
83
  const state = reactive({
80
84
  fullName: "",
85
+ password: "",
86
+ guests: 2 as number | undefined,
81
87
  budget: 50,
82
88
  quantity: 1,
89
+ code: "",
83
90
  notes: "",
84
91
  colour: "",
85
92
  subscribe: false,
93
+ reminders: true,
86
94
  services: [] as string[],
87
95
  contactMethod: "",
88
96
  themePreference: "system",
89
97
  terms: false,
90
98
  });
91
99
 
100
+ // One InputTextWithLabel per type/inputmode pair, for checking each on-screen keyboard on a real device.
101
+ const textVariants: { name: string; label: string; type: InputTypesText; inputmode: InputMode; placeholder: string }[] = [
102
+ { name: "variantEmail", label: "Email", type: "email", inputmode: "email", placeholder: "eg. jane@example.com" },
103
+ { name: "variantTel", label: "Phone", type: "tel", inputmode: "tel", placeholder: "eg. 07700 900123" },
104
+ { name: "variantUrl", label: "Website", type: "url", inputmode: "url", placeholder: "eg. https://example.com" },
105
+ { name: "variantSearch", label: "Search", type: "text", inputmode: "search", placeholder: "Search treatments" },
106
+ { name: "variantNumber", label: "Age", type: "number", inputmode: "numeric", placeholder: "eg. 34" },
107
+ { name: "variantNumeric", label: "One-time code", type: "text", inputmode: "numeric", placeholder: "eg. 123456" },
108
+ { name: "variantDecimal", label: "Hair length (cm)", type: "text", inputmode: "decimal", placeholder: "eg. 12.5" },
109
+ { name: "variantDate", label: "Appointment date", type: "date", inputmode: "text", placeholder: "" },
110
+ ];
111
+ const textVariantValues = reactive<Record<string, string>>(
112
+ Object.fromEntries(textVariants.map((variant) => [variant.name, ""]))
113
+ );
114
+
92
115
  const serviceOptions = reactive<IFormMultipleOptions>({
93
116
  data: [
94
117
  { id: "1", name: "services", value: "cut", label: "Cut" },
@@ -142,12 +165,14 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
142
165
  // Demo-only "validation" — a real consuming app would wire this through something like
143
166
  // useZodValidation instead (see this file's top comment). Kept deliberately simple so the
144
167
  // Continue button has an obvious, reliable way to trigger each field's error state for
145
- // exercising InputTextWithLabel/InputRangeDefault/InputNumberField/InputTextareaWithLabel/
146
- // InputSelectWithLabel/MultipleCheckboxes/MultipleRadiobuttons/SingleCheckbox's error UI in Storybook.
168
+ // exercising every field's error UI in Storybook.
147
169
  const errors = reactive({
148
170
  fullName: "",
171
+ password: "",
172
+ guests: "",
149
173
  budget: "",
150
174
  quantity: "",
175
+ code: "",
151
176
  notes: "",
152
177
  colour: "",
153
178
  services: "",
@@ -157,8 +182,11 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
157
182
 
158
183
  const validate = () => {
159
184
  errors.fullName = state.fullName.trim() ? "" : "Full name is required";
185
+ errors.password = state.password.length >= 8 ? "" : "Password must be at least 8 characters";
186
+ errors.guests = (state.guests ?? 0) >= 1 ? "" : "At least 1 guest is required";
160
187
  errors.budget = state.budget >= 100 ? "" : "Budget must be at least £100";
161
188
  errors.quantity = state.quantity >= 1 ? "" : "Quantity must be at least 1";
189
+ errors.code = /^\d{6}$/.test(state.code) ? "" : "Enter the 6-digit code";
162
190
  errors.notes = state.notes.trim() ? "" : "Notes are required";
163
191
  errors.colour = state.colour ? "" : "Please choose a colour";
164
192
  errors.services = state.services.length ? "" : "Choose at least one service";
@@ -166,10 +194,23 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
166
194
  errors.terms = state.terms ? "" : "You must agree to the terms";
167
195
  };
168
196
 
197
+ // Simulated round trip so the Continue button shows PendingEffect before the errors land.
198
+ const isSubmitting = ref(false);
199
+ const submit = () => {
200
+ isSubmitting.value = true;
201
+ setTimeout(() => {
202
+ isSubmitting.value = false;
203
+ validate();
204
+ }, 1500);
205
+ };
206
+
169
207
  const clearErrors = () => {
170
208
  errors.fullName = "";
209
+ errors.password = "";
210
+ errors.guests = "";
171
211
  errors.budget = "";
172
212
  errors.quantity = "";
213
+ errors.code = "";
173
214
  errors.notes = "";
174
215
  errors.colour = "";
175
216
  errors.services = "";
@@ -181,11 +222,14 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
181
222
  args,
182
223
  state,
183
224
  errors,
184
- validate,
225
+ submit,
226
+ isSubmitting,
185
227
  clearErrors,
186
228
  colourOptions,
187
229
  serviceOptions,
188
230
  contactMethodOptions,
231
+ textVariants,
232
+ textVariantValues,
189
233
  themeOptions,
190
234
  };
191
235
  },
@@ -198,13 +242,14 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
198
242
  :style-class-passthrough="['mbe-20']"
199
243
  />
200
244
  <p style="margin: 0 0 2rem 0; color: #475569; font-size: 1.4rem;">
201
- One field per 05.forms component that's fully migrated (5/5 on the Component Ledger).
202
- Click Continue with an empty name, a budget under £100, a quantity under 1, empty notes,
203
- no colour or no contact method chosen to see the error states.
245
+ Every 05.forms component that is fully migrated (5/5 on the Component Ledger).
246
+ Click Continue to see the pending state, then the error states for any empty or
247
+ out-of-range field: name, password under 8 characters, no guests, budget under £100,
248
+ quantity under 1, verification code, notes, colour, services, contact method or terms.
204
249
  </p>
205
250
 
206
251
  <FormWrapper width="medium">
207
- <form novalidate @submit.prevent="validate">
252
+ <form novalidate @submit.prevent="submit">
208
253
  <FormField width="wide" :has-gutter="false">
209
254
  <InputTextWithLabel
210
255
  v-model="state.fullName"
@@ -221,6 +266,35 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
221
266
  </InputTextWithLabel>
222
267
  </FormField>
223
268
 
269
+ <FormField width="wide" :has-gutter="false">
270
+ <InputPasswordWithLabel
271
+ v-model="state.password"
272
+ name="password"
273
+ label="Password"
274
+ :error-message="errors.password"
275
+ :field-has-error="!!errors.password"
276
+ :required="true"
277
+ :input-variant="args.inputVariant"
278
+ >
279
+ <template #descriptionText>At least 8 characters</template>
280
+ </InputPasswordWithLabel>
281
+ </FormField>
282
+
283
+ <FormField v-for="variant in textVariants" :key="variant.name" width="wide" :has-gutter="false">
284
+ <InputTextWithLabel
285
+ v-model="textVariantValues[variant.name]"
286
+ :type="variant.type"
287
+ :inputmode="variant.inputmode"
288
+ :name="variant.name"
289
+ :label="variant.label"
290
+ :placeholder="variant.placeholder"
291
+ error-message=""
292
+ :input-variant="args.inputVariant"
293
+ >
294
+ <template #descriptionText>type="{{ variant.type }}", inputmode="{{ variant.inputmode }}"</template>
295
+ </InputTextWithLabel>
296
+ </FormField>
297
+
224
298
  <FormField width="wide" :has-gutter="false">
225
299
  <InputRangeDefault
226
300
  v-model="state.budget"
@@ -253,6 +327,35 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
253
327
  </InputNumberField>
254
328
  </FormField>
255
329
 
330
+ <FormField width="wide" :has-gutter="false">
331
+ <InputOtpField
332
+ v-model="state.code"
333
+ name="code"
334
+ label="Verification code"
335
+ :error-message="errors.code"
336
+ :field-has-error="!!errors.code"
337
+ :input-variant="args.inputVariant"
338
+ >
339
+ <template #descriptionText>Enter the 6-digit code we sent to your email</template>
340
+ </InputOtpField>
341
+ </FormField>
342
+
343
+ <FormField width="wide" :has-gutter="false">
344
+ <InputTextAsNumberWithLabel
345
+ v-model="state.guests"
346
+ name="guests"
347
+ label="Guests"
348
+ :min="1"
349
+ :max="8"
350
+ :error-message="errors.guests"
351
+ :field-has-error="!!errors.guests"
352
+ :input-variant="args.inputVariant"
353
+ >
354
+ <template #left><span aria-hidden="true">−</span></template>
355
+ <template #right><span aria-hidden="true">+</span></template>
356
+ </InputTextAsNumberWithLabel>
357
+ </FormField>
358
+
256
359
  <FormField width="wide" :has-gutter="false">
257
360
  <InputTextareaWithLabel
258
361
  v-model="state.notes"
@@ -282,6 +385,10 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
282
385
  <ToggleSwitchWithLabel v-model="state.subscribe" name="subscribe" label="Subscribe to updates" />
283
386
  </FormField>
284
387
 
388
+ <FormField width="wide" :has-gutter="false">
389
+ <ToggleSwitchWithLabelInline v-model="state.reminders" name="reminders" label="Email me appointment reminders" />
390
+ </FormField>
391
+
285
392
  <FormField width="wide" :has-gutter="false">
286
393
  <MultipleCheckboxes
287
394
  v-model="state.services"
@@ -337,7 +444,14 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
337
444
 
338
445
  <FormField width="wide" :has-gutter="false">
339
446
  <div style="display: flex; gap: 1.2rem;">
340
- <InputButton type="submit" variant="primary" button-text="Continue" />
447
+ <InputButton
448
+ type="submit"
449
+ variant="primary"
450
+ button-text="Continue"
451
+ :has-pending-effect="isSubmitting"
452
+ :is-pending="isSubmitting"
453
+ :readonly="isSubmitting"
454
+ />
341
455
  <InputButton type="button" variant="tertiary" button-text="Clear errors" @click="clearErrors" />
342
456
  </div>
343
457
  </FormField>
@@ -348,11 +462,15 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
348
462
  style="margin-top: 2rem; padding: 1.6rem; border-radius: 0.8rem; background: #f8fafc; font-family: monospace; font-size: 1.3rem;"
349
463
  >
350
464
  <div>fullName: {{ state.fullName || '""' }}</div>
465
+ <div>password: {{ state.password ? "•".repeat(state.password.length) : '""' }}</div>
466
+ <div v-for="variant in textVariants" :key="variant.name">{{ variant.name }}: {{ textVariantValues[variant.name] || '""' }}</div>
467
+ <div>guests: {{ state.guests ?? "undefined" }}</div>
351
468
  <div>budget: £{{ state.budget }}</div>
352
469
  <div>quantity: {{ state.quantity }}</div>
353
470
  <div>notes: {{ state.notes || '""' }}</div>
354
471
  <div>colour: {{ state.colour || '""' }}</div>
355
472
  <div>subscribe: {{ state.subscribe }}</div>
473
+ <div>reminders: {{ state.reminders }}</div>
356
474
  <div>services: {{ JSON.stringify(state.services) }}</div>
357
475
  <div>contactMethod: {{ state.contactMethod || '""' }}</div>
358
476
  <div>themePreference: {{ state.themePreference }}</div>
@@ -186,10 +186,9 @@ onMounted(() => {
186
186
  position: relative;
187
187
 
188
188
  .option-group {
189
- aspect-ratio: 1;
190
189
  display: grid;
191
190
  grid-template-areas: "icon-stack";
192
- place-content: center;
191
+ place-items: center;
193
192
  background: transparent;
194
193
  border: var(--form-element-border-width) solid var(--triple-toggle-switch-option-border, #00000025);
195
194
  outline: var(--form-element-outline-width) solid transparent;
@@ -214,10 +213,18 @@ onMounted(() => {
214
213
  grid-area: icon-stack;
215
214
  display: block;
216
215
  font-size: var(--_scheme-icon-font-size);
216
+ width: 1em;
217
+ height: 1em;
217
218
  color: var(--triple-toggle-switch-option-icon-color, var(--slate-10));
219
+ transition: color calc(v-bind(stepAnimationDuration) / 2) ease-in-out;
218
220
 
219
221
  &.active {
220
222
  color: var(--triple-toggle-switch-option-icon-color-active, var(--slate-00));
223
+ transition-delay: calc(v-bind(stepAnimationDuration) / 2);
224
+ }
225
+
226
+ @media (prefers-reduced-motion: reduce) {
227
+ transition: none;
221
228
  }
222
229
 
223
230
  &:hover {
@@ -227,9 +234,10 @@ onMounted(() => {
227
234
 
228
235
  .option-input {
229
236
  grid-area: icon-stack;
237
+ appearance: none;
230
238
  opacity: 0;
231
- aspect-ratio: 1;
232
239
  width: var(--_scheme-icon-font-size);
240
+ height: var(--_scheme-icon-font-size);
233
241
 
234
242
  &:hover {
235
243
  cursor: pointer;
@@ -0,0 +1,4 @@
1
+ import type { GoogleReviewsData } from "~/types/components";
2
+
3
+ export const useGoogleReviews = () =>
4
+ useFetch<GoogleReviewsData>("/api/google-reviews", { key: "srcdev-google-reviews" });
@@ -0,0 +1,18 @@
1
+ export interface GoogleReview {
2
+ authorName: string;
3
+ authorUri?: string;
4
+ authorPhotoUri?: string;
5
+ rating: number;
6
+ text: string;
7
+ relativeTime: string;
8
+ publishTime: string;
9
+ reviewUri?: string;
10
+ }
11
+
12
+ export interface GoogleReviewsData {
13
+ placeName: string;
14
+ rating: number;
15
+ totalReviews: number;
16
+ mapsUri: string;
17
+ reviews: GoogleReview[];
18
+ }
@@ -26,3 +26,4 @@ export * from "./opening-hours.d"
26
26
  export * from "./samaritan-prompt.d"
27
27
  export * from "./profile-section.d"
28
28
  export * from "./services-card.d"
29
+ export * from "./google-reviews.d"
package/nuxt.config.ts CHANGED
@@ -19,6 +19,13 @@ export default defineNuxtConfig({
19
19
  resendApiKey: "", // NUXT_RESEND_API_KEY
20
20
  contactEmailTo: "", // NUXT_CONTACT_EMAIL_TO — inbox that receives enquiries
21
21
  contactEmailFrom: "", // NUXT_CONTACT_EMAIL_FROM — must be a verified Resend domain
22
+ // Used by server/api/google-reviews.get.ts (GoogleReviews component)
23
+ googleReviews: {
24
+ apiKey: "", // NUXT_GOOGLE_REVIEWS_API_KEY — Places API (New) key
25
+ placeId: "", // NUXT_GOOGLE_REVIEWS_PLACE_ID
26
+ languageCode: "", // NUXT_GOOGLE_REVIEWS_LANGUAGE_CODE — optional, e.g. en-GB
27
+ cacheMaxAge: 3600, // NUXT_GOOGLE_REVIEWS_CACHE_MAX_AGE — seconds
28
+ },
22
29
  public: {
23
30
  whatsappNumber: "", // NUXT_PUBLIC_WHATSAPP_NUMBER — in international format, no + or spaces, e.g. 447700900000
24
31
  // Consumer apps that don't support dark/light mode can opt out entirely:
@@ -127,15 +134,8 @@ export default defineNuxtConfig({
127
134
  // In Storybook, use 'none' provider so images render as plain /images/... paths
128
135
  // (avoids /_vercel/image which has no source images in storybook-static/)
129
136
  provider: process.env.STORYBOOK ? "none" : undefined,
130
- },
131
- nitro: {
132
- externals: {
133
- // Nitro's dependency tracer only copies vue's CJS build but Node.js 22 (ESM)
134
- // resolves the "import"+"node" export condition → ./index.mjs, which doesn't exist
135
- // in the output. Inlining forces Nitro/Rollup to bundle Vue directly, avoiding the
136
- // runtime resolution entirely.
137
- inline: ["vue", "@vue/runtime-core", "@vue/runtime-dom", "@vue/reactivity", "@vue/shared", "@vue/server-renderer"],
138
- },
137
+ // GoogleReviews reviewer photos
138
+ domains: ["lh3.googleusercontent.com"],
139
139
  },
140
140
  vite: {
141
141
  optimizeDeps: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.5.3",
4
+ "version": "9.5.5",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",
@@ -89,7 +89,7 @@
89
89
  }
90
90
  },
91
91
  "devDependencies": {
92
- "@chromatic-com/storybook": "4.1.2",
92
+ "@chromatic-com/storybook": "5.4.0",
93
93
  "@iconify-json/akar-icons": "1.2.7",
94
94
  "@iconify-json/bi": "1.2.7",
95
95
  "@iconify-json/bitcoin-icons": "1.2.5",
@@ -103,21 +103,22 @@
103
103
  "@nuxt/eslint": "1.17.0",
104
104
  "@nuxt/schema": "4.4.6",
105
105
  "@nuxt/test-utils": "4.1.0",
106
- "@nuxtjs/storybook": "9.1.0-29374011.dab79ae",
106
+ "@nuxtjs/storybook": "10.0.0",
107
107
  "@playwright/test": "1.62.1",
108
- "@storybook/addon-a11y": "10.0.7",
109
- "@storybook/addon-docs": "10.0.7",
110
- "@storybook/builder-vite": "10.0.7",
108
+ "@storybook/addon-a11y": "10.6.1",
109
+ "@storybook/addon-docs": "10.6.1",
110
+ "@storybook/addon-mcp": "10.6.1",
111
+ "@storybook/builder-vite": "10.6.1",
111
112
  "@vue/test-utils": "2.4.11",
112
113
  "@vueuse/core": "14.4.0",
113
114
  "eslint": "10.8.1",
114
- "eslint-plugin-storybook": "10.0.7",
115
+ "eslint-plugin-storybook": "10.6.1",
115
116
  "happy-dom": "20.11.2",
116
117
  "nuxt-qrcode": "0.4.10",
117
118
  "oxc-parser": "0.131.0",
118
119
  "patch-package": "8.0.1",
119
120
  "release-it": "21.0.2",
120
- "storybook": "10.0.7",
121
+ "storybook": "10.6.1",
121
122
  "typescript": "5.9.3",
122
123
  "vitest": "4.1.11",
123
124
  "vue-router": "5.2.0",