srcdev-nuxt-components 9.5.2 → 9.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) 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/canvas-switcher.md +5 -1
  5. package/.claude/skills/components/carousel-flip.md +3 -3
  6. package/.claude/skills/components/clipped-panel.md +2 -2
  7. package/.claude/skills/components/dashboard-quad-grid.md +2 -2
  8. package/.claude/skills/components/dashboard-stats-grid.md +1 -1
  9. package/.claude/skills/components/deep-expanding-menu-classic.md +1 -1
  10. package/.claude/skills/components/deep-expanding-menu.md +1 -1
  11. package/.claude/skills/components/eyebrow-text.md +6 -4
  12. package/.claude/skills/components/google-reviews.md +193 -0
  13. package/.claude/skills/components/input-number.md +4 -0
  14. package/.claude/skills/components/input-otp.md +120 -0
  15. package/.claude/skills/components/section-parallax.md +1 -1
  16. package/.claude/skills/components/select-menu.md +9 -5
  17. package/.claude/skills/components/services-card.md +25 -2
  18. package/.claude/skills/composable-colour-scheme.md +2 -0
  19. package/.claude/skills/index.md +5 -1
  20. package/.claude/skills/new-app-scaffold.md +1 -1
  21. package/.claude/skills/storybook-add-story.md +5 -0
  22. package/.claude/skills/theming-colour-ramps.md +6 -33
  23. package/.claude/skills/theming-component-token-pattern.md +30 -0
  24. package/.claude/skills/theming-dark-mode.md +243 -0
  25. package/.claude/skills/theming-override-default.md +6 -7
  26. package/.claude/skills/theming-partial-override.md +11 -11
  27. package/.vscode/srcdev-component-eyebrow-text.code-snippets +4 -0
  28. package/.vscode/srcdev-component-google-reviews.code-snippets +78 -0
  29. package/.vscode/srcdev-component-input-otp.code-snippets +47 -0
  30. package/.vscode/srcdev-component-select-menu.code-snippets +13 -0
  31. package/.vscode/srcdev-component-services-card.code-snippets +38 -7
  32. package/README.md +81 -41
  33. package/app/assets/styles/setup/01.config/_head.css +0 -9
  34. package/app/assets/styles/setup/03.theming/_default.css +29 -29
  35. package/app/assets/styles/setup/03.theming/_error.css +3 -3
  36. package/app/assets/styles/setup/03.theming/_theme-slots.css +13 -16
  37. package/app/assets/styles/setup/a11y/_variables.css +2 -2
  38. package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +47 -13
  39. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +5 -1
  40. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +4 -1
  41. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +7 -7
  42. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +10 -0
  43. package/app/components/02.molecules/select-menu/SelectMenu.vue +15 -1
  44. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +35 -0
  45. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +9 -0
  46. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +3 -3
  47. package/app/components/03.organisms/google-reviews/CONSUMER-STYLING.md +110 -0
  48. package/app/components/03.organisms/google-reviews/GoogleReviewCard.vue +161 -0
  49. package/app/components/03.organisms/google-reviews/GoogleReviews.vue +275 -0
  50. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +323 -0
  51. package/app/components/03.organisms/google-reviews/tests/GoogleReviewCard.spec.ts +104 -0
  52. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +232 -0
  53. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +9 -0
  54. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +43 -0
  55. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +28 -3
  56. package/app/components/03.organisms/services/services-card/ServicesCard.vue +111 -18
  57. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +122 -0
  58. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +4 -1
  59. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +100 -19
  60. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +12 -3
  61. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +1 -1
  62. package/app/components/05.forms/input-number/InputNumber.vue +2 -2
  63. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +59 -0
  64. package/app/components/05.forms/input-otp/InputOtp.vue +254 -0
  65. package/app/components/05.forms/input-otp/InputOtpField.vue +106 -0
  66. package/app/components/05.forms/input-otp/stories/InputOtp.stories.ts +100 -0
  67. package/app/components/05.forms/input-otp/stories/InputOtpField.stories.ts +163 -0
  68. package/app/components/05.forms/input-otp/tests/InputOtp.spec.ts +275 -0
  69. package/app/components/05.forms/input-otp/tests/InputOtpField.spec.ts +76 -0
  70. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +151 -33
  71. package/app/composables/tests/useAriaDescribedById.spec.ts +5 -5
  72. package/app/composables/useGoogleReviews.ts +4 -0
  73. package/app/types/components/google-reviews.d.ts +18 -0
  74. package/app/types/components/index.ts +1 -0
  75. package/nuxt.config.ts +9 -9
  76. package/package.json +9 -8
@@ -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
  }
@@ -1,5 +1,8 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import { ref } from "vue";
2
3
  import PageRowComponent from "../PageRow.vue";
4
+ import CanvasSwitcher from "../../canvas-switcher/CanvasSwitcher.vue";
5
+ import type { MediaCanvas } from "~/types/components";
3
6
 
4
7
  interface PageRowArgs {
5
8
  tag: string;
@@ -47,7 +50,27 @@ export default {
47
50
  id: "",
48
51
  styleClassPassthrough: [],
49
52
  },
53
+ decorators: [
54
+ // PageRow sizes its tracks from 100% of its container, so a constrained wrapper stands in for a narrower viewport.
55
+ (story) => ({
56
+ components: { story, CanvasSwitcher },
57
+ setup() {
58
+ const canvasName = ref<MediaCanvas>("fullWidthCanvas");
59
+ return { canvasName };
60
+ },
61
+ template: `
62
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
63
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
64
+ </div>
65
+ <div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
66
+ <story />
67
+ </div>
68
+ `,
69
+ }),
70
+ ],
50
71
  parameters: {
72
+ // Storybook's default padded layout hides the viewport gutter and align edge-bleed.
73
+ layout: "fullscreen",
51
74
  docs: {
52
75
  description: {
53
76
  component:
@@ -118,26 +141,37 @@ InsetContent.parameters = {
118
141
  const NestingTemplate: StoryFn = () => ({
119
142
  components: { PageRowComponent },
120
143
  setup() {
121
- return { sampleText };
144
+ const nested = [
145
+ { variant: "full", background: "#fca5a5", desc: "edge to edge" },
146
+ { variant: "popout", background: "#fdba74", desc: "max 1400px" },
147
+ { variant: "content", background: "#fde047", desc: "max 1064px" },
148
+ { variant: "inset-content", background: "#86efac", desc: "max 840px" },
149
+ ];
150
+ const chip =
151
+ "display: inline-block; margin-block-end: 8px; padding: 2px 8px; border-radius: 4px; background: rgba(0,0,0,0.75); color: white; font-family: monospace; font-size: 1.2rem;";
152
+ return { sampleText, nested, chip };
122
153
  },
123
154
  template: `
124
155
  <div style="min-height: 100vh;">
125
156
 
126
- <PageRowComponent tag="section" variant="full" style="background: #1e3a5f; color: white; padding-block: 60px;">
157
+ <PageRowComponent tag="section" style="background: #1e3a5f; color: white; padding-block: 60px;">
158
+ <span :style="chip">outer PageRow (variant="content")</span>
127
159
  <h1 style="margin: 0 0 16px; font-size: 2.4rem;">Full-width background, constrained content</h1>
128
160
  <p style="margin: 0; opacity: 0.85; line-height: 1.7;">{{ sampleText }}</p>
129
161
  </PageRowComponent>
130
162
 
131
- <PageRowComponent tag="section" style="background: #f0fdf4; padding-block: 48px;">
132
- <PageRowComponent variant="content">
133
- <h2 style="margin: 0 0 12px; font-size: 1.8rem; color: #166534;">Nested: content inside a content row</h2>
134
- <p style="margin: 0; color: #15803d; line-height: 1.7;">{{ sampleText }}</p>
135
- </PageRowComponent>
136
- <PageRowComponent variant="popout" style="margin-block: 24px; background: #dcfce7; border-radius: 6px; padding: 24px;">
137
- <p style="margin: 0; color: #166534;">Popout child breaking out to 1400px</p>
138
- </PageRowComponent>
139
- <PageRowComponent variant="inset-content">
140
- <p style="margin: 0; color: #15803d; font-style: italic; line-height: 1.7;">Inset content (840px) for a focused quote or aside.</p>
163
+ <PageRowComponent tag="section" style="background: #0f172a; padding-block: 48px; display: grid; row-gap: 16px;">
164
+ <span :style="chip" style="background: white; color: #0f172a;">outer PageRow (dark background)</span>
165
+ <PageRowComponent
166
+ v-for="row in nested"
167
+ :key="row.variant"
168
+ :variant="row.variant"
169
+ :style="{ background: row.background, color: '#111827', paddingBlock: '24px' }"
170
+ >
171
+ <div>
172
+ <span :style="chip">nested variant="{{ row.variant }}"</span>
173
+ <p style="margin: 0; line-height: 1.6;">{{ row.desc }}: the background shows the track this nested PageRow occupies.</p>
174
+ </div>
141
175
  </PageRowComponent>
142
176
  </PageRowComponent>
143
177
 
@@ -152,7 +186,7 @@ NestingPattern.parameters = {
152
186
  docs: {
153
187
  description: {
154
188
  story:
155
- "The key composition pattern: nest a full-width outer PageRow (for backgrounds/borders) with inner PageRows at narrower variants. No extra wrapper divs needed.",
189
+ "The key composition pattern: an outer PageRow carries the full-width background, and nested PageRows pick a track via variant. Each nested row's background marks the track it occupies against the dark outer row. No extra wrapper divs needed.",
156
190
  },
157
191
  },
158
192
  };
@@ -8,8 +8,12 @@
8
8
  | `--eyebrow-text-large` | `1.4rem` | Font size for `font-size="large"` |
9
9
  | `--eyebrow-text-medium` | `1.2rem` | Font size for `font-size="medium"` (default) |
10
10
  | `--eyebrow-text-small` | `1rem` | Font size for `font-size="small"` |
11
+ | `--eyebrow-text-font-family` | `inherit` | Font family |
12
+ | `--eyebrow-text-font-style` | `italic` | Font style, e.g. `normal` for upright text |
13
+ | `--eyebrow-text-font-weight` | `inherit` | Font weight |
14
+ | `--eyebrow-text-letter-spacing` | `inherit` | Letter-spacing, e.g. `0.25em` for wide tracking |
11
15
 
12
- The library theme also sets all four on `:root` (`03.theming/_default.css` and
16
+ The library theme also sets the colour and size tokens on `:root` (`03.theming/_default.css` and
13
17
  `05.typography/01.tokens/_reponsive-font-sizes.css`) with the same values.
14
18
 
15
19
  > Changed 2026-09-27: the component now carries those values as fallbacks too. Before, an app that
@@ -31,7 +31,10 @@ watch(
31
31
  text-transform: uppercase;
32
32
  background-clip: text;
33
33
  background-image: var(--eyebrow-text-bg-img, linear-gradient(135deg, #c2a770, #b4747e, #d1bd94));
34
- font-style: italic;
34
+ font-family: var(--eyebrow-text-font-family, inherit);
35
+ font-style: var(--eyebrow-text-font-style, italic);
36
+ font-weight: var(--eyebrow-text-font-weight, inherit);
37
+ letter-spacing: var(--eyebrow-text-letter-spacing, inherit);
35
38
  color: transparent;
36
39
 
37
40
  &.large {
@@ -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 |
@@ -54,6 +55,15 @@ global token so it matches the rest of the design system out of the box.
54
55
 
55
56
  ---
56
57
 
58
+ ## State hooks
59
+
60
+ | Hook | Where | Meaning |
61
+ |---|---|---|
62
+ | `.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. |
63
+ | `:has(.select-menu-popover:popover-open)` | `.select-menu` root | Menu open (rotates the chevron). |
64
+
65
+ ---
66
+
57
67
  ## Global theming
58
68
 
59
69
  Create `assets/styles/setup/07.components/select-menu.css` in the consuming app and set tokens
@@ -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"
@@ -61,6 +61,7 @@
61
61
 
62
62
  <script setup lang="ts">
63
63
  import type { SelectMenuOption } from "~/types/components/select-menu";
64
+ import type { InputUiVariant } from "~/types/forms/types.forms";
64
65
 
65
66
  interface Props {
66
67
  /** The full list of selectable options. */
@@ -79,6 +80,8 @@ interface Props {
79
80
  multiple?: boolean;
80
81
  /** 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
82
  showSelectionInTrigger?: boolean;
83
+ /** Trigger border style, matching InputSelect: `normal` (bordered box) or `underlined` (bottom border only). */
84
+ inputVariant?: InputUiVariant;
82
85
  styleClassPassthrough?: string | string[];
83
86
  }
84
87
 
@@ -89,6 +92,7 @@ const props = withDefaults(defineProps<Props>(), {
89
92
  showChevron: true,
90
93
  multiple: false,
91
94
  showSelectionInTrigger: false,
95
+ inputVariant: "normal",
92
96
  styleClassPassthrough: () => [],
93
97
  });
94
98
 
@@ -273,6 +277,16 @@ watch(
273
277
  }
274
278
  }
275
279
 
280
+ &.underlined .select-menu-trigger {
281
+ border-block-start-width: 0;
282
+ border-inline-width: 0;
283
+ border-block-end-width: var(
284
+ --select-menu-trigger-border-width-underlined,
285
+ var(--form-element-border-bottom-width-underlined, 0.3rem)
286
+ );
287
+ border-radius: 0;
288
+ }
289
+
276
290
  &:has(.select-menu-popover:popover-open) .select-menu-trigger-chevron {
277
291
  transform: rotate(180deg);
278
292
  }
@@ -14,6 +14,7 @@ interface StoryArgs {
14
14
  showChevron?: boolean;
15
15
  multiple?: boolean;
16
16
  showSelectionInTrigger?: boolean;
17
+ inputVariant?: "normal" | "underlined";
17
18
  modelValue?: string | number | (string | number)[];
18
19
  styleClassPassthrough?: string | string[];
19
20
  }
@@ -57,6 +58,12 @@ const meta: Meta<StoryArgs> = {
57
58
  description: "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.",
58
59
  table: { category: "Content" },
59
60
  },
61
+ inputVariant: {
62
+ control: { type: "select" },
63
+ options: ["normal", "underlined"],
64
+ description: "Trigger border style, matching InputSelect: bordered box or bottom border only.",
65
+ table: { category: "Styling" },
66
+ },
60
67
  styleClassPassthrough: {
61
68
  table: { disable: true },
62
69
  },
@@ -66,6 +73,7 @@ const meta: Meta<StoryArgs> = {
66
73
  showIcon: true,
67
74
  showLabel: true,
68
75
  showChevron: true,
76
+ inputVariant: "normal",
69
77
  },
70
78
  parameters: {
71
79
  docs: {
@@ -357,3 +365,30 @@ export const CustomTokens: Story = {
357
365
  `,
358
366
  }),
359
367
  };
368
+
369
+ /**
370
+ * Underlined — bottom border only, matching InputSelect's `underlined` variant
371
+ * so a SelectMenu can sit next to underlined form fields.
372
+ */
373
+ export const Underlined: Story = {
374
+ args: {
375
+ label: "Choose a service",
376
+ showIcon: false,
377
+ inputVariant: "underlined",
378
+ },
379
+ render: (args) => ({
380
+ components: { SelectMenu },
381
+ setup() {
382
+ const value = ref<string | undefined>(undefined);
383
+ return { args, value, serviceOptions };
384
+ },
385
+ template: `
386
+ <div style="padding: 4rem 8rem;">
387
+ <SelectMenu v-bind="args" v-model="value" :options="serviceOptions" />
388
+ <p style="margin: 1.6rem 0 0; font-size: 1.3rem; opacity: 0.6;">
389
+ modelValue: <strong>{{ value === undefined ? 'undefined' : value }}</strong>
390
+ </p>
391
+ </div>
392
+ `,
393
+ }),
394
+ };
@@ -89,6 +89,15 @@ describe("SelectMenu", () => {
89
89
  expect(wrapper.find(".select-menu-trigger").attributes("aria-haspopup")).toBe("listbox");
90
90
  });
91
91
 
92
+ it("applies the normal variant class by default and underlined when set", async () => {
93
+ wrapper = await createWrapper();
94
+ expect(wrapper.find(".select-menu").classes()).toContain("normal");
95
+
96
+ await wrapper.setProps({ inputVariant: "underlined" });
97
+ expect(wrapper.find(".select-menu").classes()).toContain("underlined");
98
+ expect(wrapper.find(".select-menu").classes()).not.toContain("normal");
99
+ });
100
+
92
101
  it("uses label prop as aria-label", async () => {
93
102
  wrapper = await createWrapper({ label: "Filter by service" });
94
103
  expect(wrapper.find(".select-menu-trigger").attributes("aria-label")).toBe("Filter by service");
@@ -1,7 +1,7 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`SelectMenu > Snapshots > default 1`] = `
4
- "<div class="select-menu" style="--_anchor-name: --select-menu-anchor-v-0-0;"><button popovertarget="select-menu-v-0-0" popovertargetaction="toggle" type="button" class="select-menu-trigger" aria-label="Language" aria-haspopup="listbox" aria-expanded="false">
4
+ "<div class="select-menu normal" style="--_anchor-name: --select-menu-anchor-v-0-0;"><button popovertarget="select-menu-v-0-0" popovertargetaction="toggle" type="button" class="select-menu-trigger" aria-label="Language" aria-haspopup="listbox" aria-expanded="false">
5
5
  <!--v-if--><span class="select-menu-trigger-label">Language</span><span class="iconify i-lucide:chevron-down select-menu-trigger-chevron" aria-hidden="true"></span>
6
6
  </button>
7
7
  <div id="select-menu-v-0-0" popover="" class="select-menu-popover">
@@ -15,7 +15,7 @@ exports[`SelectMenu > Snapshots > default 1`] = `
15
15
  `;
16
16
 
17
17
  exports[`SelectMenu > Snapshots > icon-only trigger 1`] = `
18
- "<div class="select-menu" style="--_anchor-name: --select-menu-anchor-v-0-0;"><button popovertarget="select-menu-v-0-0" popovertargetaction="toggle" type="button" class="select-menu-trigger" aria-label="Language" aria-haspopup="listbox" aria-expanded="false"><span class="iconify i-flag:gb-4x3 select-menu-trigger-icon" aria-hidden="true"></span>
18
+ "<div class="select-menu normal" style="--_anchor-name: --select-menu-anchor-v-0-0;"><button popovertarget="select-menu-v-0-0" popovertargetaction="toggle" type="button" class="select-menu-trigger" aria-label="Language" aria-haspopup="listbox" aria-expanded="false"><span class="iconify i-flag:gb-4x3 select-menu-trigger-icon" aria-hidden="true"></span>
19
19
  <!--v-if-->
20
20
  <!--v-if-->
21
21
  </button>
@@ -30,7 +30,7 @@ exports[`SelectMenu > Snapshots > icon-only trigger 1`] = `
30
30
  `;
31
31
 
32
32
  exports[`SelectMenu > Snapshots > with a selected value 1`] = `
33
- "<div class="select-menu" style="--_anchor-name: --select-menu-anchor-v-0-0;"><button popovertarget="select-menu-v-0-0" popovertargetaction="toggle" type="button" class="select-menu-trigger" aria-label="Language" aria-haspopup="listbox" aria-expanded="false"><span class="iconify i-flag:fr-4x3 select-menu-trigger-icon" aria-hidden="true"></span><span class="select-menu-trigger-label">Français</span><span class="iconify i-lucide:chevron-down select-menu-trigger-chevron" aria-hidden="true"></span></button>
33
+ "<div class="select-menu normal" style="--_anchor-name: --select-menu-anchor-v-0-0;"><button popovertarget="select-menu-v-0-0" popovertargetaction="toggle" type="button" class="select-menu-trigger" aria-label="Language" aria-haspopup="listbox" aria-expanded="false"><span class="iconify i-flag:fr-4x3 select-menu-trigger-icon" aria-hidden="true"></span><span class="select-menu-trigger-label">Français</span><span class="iconify i-lucide:chevron-down select-menu-trigger-chevron" aria-hidden="true"></span></button>
34
34
  <div id="select-menu-v-0-0" popover="" class="select-menu-popover">
35
35
  <ul class="select-menu-list" role="listbox" aria-label="Language">
36
36
  <li class="select-menu-list-item" role="option" tabindex="-1" aria-selected="false"><span class="select-menu-item-check" aria-hidden="true"><!--v-if--></span><span class="iconify i-flag:gb-4x3 select-menu-item-icon" aria-hidden="true"></span><span class="select-menu-item-label">English</span></li>
@@ -0,0 +1,110 @@
1
+ # GoogleReviews — Consumer Styling Guide
2
+
3
+ `GoogleReviews` shows a place's Google reviews as a single horizontally scrolling row of
4
+ `GoogleReviewCard`s, with a summary above and prev/next buttons plus the Google Maps attribution
5
+ below. Both components read the same `--google-reviews-*` tokens, so setting them on an ancestor
6
+ styles a standalone `GoogleReviewCard` too.
7
+
8
+ ## Public token API
9
+
10
+ ### Row (`GoogleReviews`)
11
+
12
+ | Token | Default | Controls |
13
+ |---|---|---|
14
+ | `--google-reviews-gap` | `1.6rem` | Space between header, row and footer, and between cards |
15
+ | `--google-reviews-card-width` | `min(32rem, 85%)` | Width of each card in the row |
16
+ | `--google-reviews-scroll-padding` | `0` | Snap inset, e.g. to line cards up with a page gutter |
17
+ | `--google-reviews-scrollbar-width` | `thin` | Row scrollbar: `auto`, `thin` or `none` (`none` hides it; the buttons and touch/keyboard scrolling still work) |
18
+ | `--google-reviews-scrollbar-thumb-colour` | `var(--slate-04)` | Scrollbar thumb |
19
+ | `--google-reviews-scrollbar-track-colour` | `transparent` | Scrollbar track |
20
+ | `--google-reviews-summary-rating-font-size` | `2.4rem` | Overall rating number |
21
+ | `--google-reviews-focus-outline-width` | `2px` | Focus ring on the scrollable row |
22
+ | `--google-reviews-focus-outline-colour` | `var(--theme-border-focus)` | Focus ring colour |
23
+
24
+ ### Card (`GoogleReviewCard`)
25
+
26
+ | Token | Default | Controls |
27
+ |---|---|---|
28
+ | `--google-reviews-card-gap` | `1.2rem` | Space between author, stars, text and link |
29
+ | `--google-reviews-card-padding` | `2rem` | Card padding |
30
+ | `--google-reviews-card-border-width` | `1px` | Card border width |
31
+ | `--google-reviews-card-border-colour` | `transparent` | Card border colour |
32
+ | `--google-reviews-card-border-radius` | `0.8rem` | Card corner radius |
33
+ | `--google-reviews-card-surface` | `var(--theme-surface-subtle)` | Card background |
34
+ | `--google-reviews-card-text-colour` | `var(--theme-text)` | Card text colour |
35
+ | `--google-reviews-avatar-surface` | `var(--theme-surface)` | Initials avatar background (shown when a reviewer has no photo) |
36
+ | `--google-reviews-avatar-text-colour` | `var(--theme-on-surface)` | Initials colour |
37
+ | `--google-reviews-author-font-weight` | `600` | Reviewer name weight |
38
+ | `--google-reviews-author-line-clamp` | `2` | Lines of reviewer name before it's cut off with an ellipsis (`1` for single-line ellipsis, `none` to show it all) |
39
+ | `--google-reviews-text-line-height` | `1.5` | Review text line height |
40
+ | `--google-reviews-text-line-clamp` | `5` | Lines of review text before it's cut off with an ellipsis (`none` to show it all) |
41
+ | `--google-reviews-link-colour` | `inherit` | "Read on Google" link colour |
42
+
43
+ ### Shared
44
+
45
+ | Token | Default | Controls |
46
+ |---|---|---|
47
+ | `--google-reviews-star-size` | `1.6rem` | Star font size (summary and cards) |
48
+ | `--google-reviews-star-colour` | `var(--amber-05)` | Filled star colour |
49
+ | `--google-reviews-star-empty-colour` | `var(--slate-03)` | Unfilled star colour |
50
+ | `--google-reviews-meta-font-size` | `1.4rem` | Date, link, filter notice and attribution |
51
+ | `--google-reviews-meta-text-colour` | `inherit` | Colour of the same |
52
+
53
+ Private tokens (not public API): `--_rating` (set inline from the review's rating) and `--_fill`
54
+ (the star fill percentage derived from it).
55
+
56
+ ## State hooks
57
+
58
+ - No `data-*` state. The prev/next buttons are `InputButton`s with `readonly`/`aria-disabled` at
59
+ either end of the row, styled by `InputButton`'s own tokens.
60
+ - Inner classes: `.google-reviews-header`, `.google-reviews-summary`,
61
+ `.google-reviews-summary-rating`, `.google-reviews-stars`, `.google-reviews-summary-link`,
62
+ `.google-reviews-filter-notice`, `.google-reviews-list`, `.google-reviews-item`,
63
+ `.google-reviews-footer`, `.google-reviews-controls`, `.google-reviews-prev`,
64
+ `.google-reviews-next`, `.google-reviews-attribution`.
65
+ - Card classes: `.google-review-card`, `.google-review-card-author`,
66
+ `.google-review-card-author-name`,
67
+ `.google-review-card-time`, `.google-review-card-rating`, `.google-review-card-stars`,
68
+ `.google-review-card-text`, `.google-review-card-link`.
69
+
70
+ ## Row alignment
71
+
72
+ Inside `GoogleReviews`, every card spans the list's five shared rows (name, date, rating, text,
73
+ link) through `grid-template-rows: subgrid`, so each section lines up across the whole row
74
+ however tall its neighbours are: a one-line name's date sits level with a two-line name's. The
75
+ author block is itself a subgrid over the name and date rows, with the avatar spanning both.
76
+ Sections sit in fixed rows, so a card with no date, text or link leaves the gap rather than
77
+ shifting up. Spacing between sections is a top margin (`--google-reviews-card-gap`), not a grid
78
+ gap, so the name and date stay close together. A standalone `GoogleReviewCard` uses
79
+ `auto auto auto 1fr auto` instead (link pinned to the bottom). Content from the `card` slot
80
+ spans all five rows as one block. Browsers
81
+ without subgrid fall back to equal-height cards that aren't aligned section by section.
82
+
83
+ ## Stars
84
+
85
+ Stars are the text `★★★★★` filled with a hard-stop gradient clipped to the text, so fractional
86
+ ratings (4.6) show a partly filled star. They're `aria-hidden`; the rating is read out from
87
+ visually hidden text (`ratingLabel`).
88
+
89
+ ## Local overrides
90
+
91
+ Set the tokens above on an element you own (a page or section class, or a class added with
92
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
93
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
94
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
95
+ `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
96
+
97
+ **Caveat:** the card re-declares `--display-avatar-background` and `--display-avatar-text-colour` on its
98
+ avatar, so setting those `DisplayAvatar` tokens on an ancestor has no effect here. Use
99
+ `--google-reviews-avatar-surface` and `--google-reviews-avatar-text-colour` instead.
100
+
101
+ ## Class passthrough
102
+
103
+ `style-class-passthrough` adds classes to the root (`.google-reviews` or `.google-review-card`).
104
+ `GoogleReviews` doesn't forward it to the cards it renders; set tokens on the root instead, or
105
+ use the `card` slot to render `GoogleReviewCard` yourself with its own passthrough.
106
+
107
+ ## Notes
108
+
109
+ Google's terms require the attribution text to stay visible and readable. Restyle it, but don't
110
+ hide it, and keep the words "Google Maps" in `attributionText` when translating.