srcdev-nuxt-components 9.3.11 → 9.3.13

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 (77) hide show
  1. package/.claude/skills/component-aria-landmark.md +3 -3
  2. package/.claude/skills/components/auto-grid.md +1 -1
  3. package/.claude/skills/components/page-hero-highlights.md +1 -1
  4. package/.claude/skills/components/service-detail.md +13 -14
  5. package/.claude/skills/components/service-summary-grid.md +141 -0
  6. package/.claude/skills/components/service-summary.md +165 -0
  7. package/.claude/skills/icon-sets.md +1 -1
  8. package/.claude/skills/index.md +3 -2
  9. package/.claude/skills/theming-component-token-pattern.md +126 -0
  10. package/.vscode/srcdev-component-service-summary.code-snippets +67 -0
  11. package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +10 -1
  12. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +3 -3
  13. package/app/components/02.molecules/input-copy/InputCopy.vue +9 -3
  14. package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +3 -3
  15. package/app/components/02.molecules/pricing-card/PricingCard.vue +6 -3
  16. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +7 -8
  17. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +0 -1
  18. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +2 -2
  19. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +116 -0
  20. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +183 -0
  21. package/app/components/03.organisms/services/{services-section/stories/ServicesSection.stories.ts → service-summary/stories/ServiceSummary.stories.ts} +78 -148
  22. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +210 -0
  23. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +17 -0
  24. package/app/components/03.organisms/services/services-grids/{ServicesSectionGrid.vue → ServiceSummaryGrid.vue} +8 -10
  25. package/app/components/03.organisms/services/services-grids/stories/{ServicesSectionGrid.stories.ts → ServiceSummaryGrid.stories.ts} +21 -21
  26. package/app/components/03.organisms/services/services-grids/tests/{ServicesSectionGrid.spec.ts → ServiceSummaryGrid.spec.ts} +28 -28
  27. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +45 -0
  28. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +117 -0
  29. package/app/components/05.forms/input-button/InputButtonCore.vue +83 -37
  30. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-secondary-chromium-darwin.png +0 -0
  31. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-tertiary-chromium-darwin.png +0 -0
  32. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-primary-pending-chromium-darwin.png +0 -0
  33. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-secondary-pill-chromium-darwin.png +0 -0
  34. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-tertiary-readonly-chromium-darwin.png +0 -0
  35. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-chromium-darwin.png +0 -0
  36. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-default-chromium-darwin.png +0 -0
  37. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-error-chromium-darwin.png +0 -0
  38. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-success-chromium-darwin.png +0 -0
  39. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-warning-chromium-darwin.png +0 -0
  40. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-primary-chromium-darwin.png +0 -0
  41. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-secondary-chromium-darwin.png +0 -0
  42. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-tertiary-chromium-darwin.png +0 -0
  43. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/nuxt-icon-only-component-chromium-darwin.png +0 -0
  44. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pending-chromium-darwin.png +0 -0
  45. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pill-chromium-darwin.png +0 -0
  46. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-readonly-chromium-darwin.png +0 -0
  47. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/with-both-nuxt-icon-components-chromium-darwin.png +0 -0
  48. package/app/components/05.forms/input-button/stories/InputButtonCore.stories.ts +35 -0
  49. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +68 -0
  50. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +19 -9
  51. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioCore.vue +8 -5
  52. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +93 -0
  53. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioCore.stories.ts +75 -0
  54. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +41 -0
  55. package/app/components/05.forms/input-number/InputNumberCore.vue +12 -6
  56. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +35 -29
  57. package/app/components/05.forms/input-select/InputSelectCore.vue +32 -20
  58. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +69 -0
  59. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +38 -48
  60. package/app/components/05.forms/input-text/InputTextCore.vue +35 -18
  61. package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +184 -0
  62. package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +125 -0
  63. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +49 -0
  64. package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +23 -16
  65. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +72 -0
  66. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +42 -20
  67. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitchCore.stories.ts +63 -0
  68. package/app/components/display-theme-switch/DisplayThemeSwitch.vue +13 -6
  69. package/package.json +1 -1
  70. package/.claude/skills/components/services-section-grid.md +0 -130
  71. package/.claude/skills/components/services-section.md +0 -211
  72. package/.vscode/srcdev-component-services-section.code-snippets +0 -84
  73. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +0 -87
  74. package/app/components/03.organisms/services/services-section/CONSUMER-STYLING.md +0 -149
  75. package/app/components/03.organisms/services/services-section/ServicesSection.vue +0 -345
  76. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +0 -297
  77. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +0 -87
@@ -31,9 +31,9 @@ correctly** — always check the console (or run an accessibility audit) after a
31
31
 
32
32
  This bug class previously shipped to production undetected: eight sections across one site had
33
33
  `aria-labelledby` pointing at ids that were never applied to anything, only surfaced by a WAVE
34
- audit. Two components (`ServicesSection`, `AutoGrid`) were also found to be *structurally* broken
34
+ audit. Two components (`ServiceSummary` (formerly ServicesSection), `AutoGrid`) were also found to be *structurally* broken
35
35
  — they set `aria-labelledby` from `tag` alone without ever exposing `headingId` anywhere a
36
- consumer could bind it, so it was impossible to satisfy correctly. `ServicesSection` now binds
36
+ consumer could bind it, so it was impossible to satisfy correctly. `ServiceSummary` now binds
37
37
  `headingId` to its own internal title heading; `AutoGrid` has no heading concept at all, so it no
38
38
  longer sets `aria-labelledby` under any circumstance (pass `aria-label` directly if needed).
39
39
 
@@ -87,7 +87,7 @@ must bind it to their own heading):
87
87
  Self-bound (the component renders its own heading and binds `headingId` internally — no consumer
88
88
  action needed):
89
89
 
90
- - `ServicesSection` (03.organisms) — binds it to its own title `HeroText`
90
+ - `ServiceSummary` (03.organisms) — binds it to its own title `HeroText`
91
91
  - `LayoutGridByCols` / `LayoutGridByWidth` (01.atoms) — render their own visually-hidden `<p>` from the `label` prop
92
92
 
93
93
  Not using this pattern:
@@ -95,7 +95,7 @@ Override `grid-template-columns` directly — there is no single token for this:
95
95
 
96
96
  `AutoGrid` has no heading concept of its own — its slots are arbitrary named items, not a
97
97
  header + body — so it does **not** auto-generate `aria-labelledby` the way `PageRow` or
98
- `ServicesSection` do (an earlier version of this component did attempt to, and it produced a
98
+ `ServiceSummary` do (an earlier version of this component did attempt to, and it produced a
99
99
  guaranteed broken ARIA reference, since there was never any way to bind a heading to it). If
100
100
  `tag="section"` needs an accessible name, pass `aria-label` directly:
101
101
 
@@ -61,7 +61,7 @@ Each slot accepts any content, but these library components are natural fits:
61
61
  | `#header` | `HeroText` | Heading with accent text, icon, and configurable size — wires `headingId` for `aria-labelledby` |
62
62
  | `#highlights` | `ServicesCard` (×n) | Portrait cards with image, title, description, and CTA slot |
63
63
  | `#highlights` | `LayoutGridByCols` wrapping cards | When you want a responsive column grid rather than a single row of cards |
64
- | `#content` | Any content component | Below the straddle — safe to use `PageRow`, `ServicesSection`, etc. |
64
+ | `#content` | Any content component | Below the straddle — safe to use `PageRow`, `ServiceSummary`, `ServiceDetail`, etc. |
65
65
 
66
66
  Example with `HeroText` in the header slot:
67
67
 
@@ -21,12 +21,11 @@ CTA banner are each their own `PageRow` too, with their own variant props (`body
21
21
  if a particular page wants a section to sit in a different track (e.g.
22
22
  `heroContentVariant="inset-content"` for a narrower hero text column).
23
23
 
24
- It is a different shape from [ServicesSection](services-section.md): `ServicesSection` renders
25
- image-beside-content (used both as a compact summary card in a list and, in full mode, as a
26
- same-page detail block); `ServiceDetail` is meant to *be* the whole page for one service —
27
- `headerTag` defaults to `"h1"` accordingly, and there is no summary/compact mode. There is no
28
- `isSummary` prop and no `v-if` gating of content — everything the component renders is always
29
- rendered.
24
+ It is a different shape from [ServiceSummary](service-summary.md): `ServiceSummary` renders a
25
+ compact image-beside-content preview for a listing page; `ServiceDetail` is meant to *be* the
26
+ whole page for one service — `headerTag` defaults to `"h1"` accordingly, and there is no
27
+ summary/compact mode. There is no `v-if` gating of content — everything the component renders is
28
+ always rendered.
30
29
 
31
30
  ## Props
32
31
 
@@ -63,12 +62,12 @@ type** — neither is universal enough to belong on `~/types/types.services`. Th
63
62
  the whole "You may also like" block simply don't render when they're omitted.
64
63
 
65
64
  `imageLoading`/`imageFetchPriority` default to eager/high because a `ServiceDetail` page normally
66
- has exactly one hero image and it's always the LCP candidate — unlike `ServicesSection`, which is
65
+ has exactly one hero image and it's always the LCP candidate — unlike `ServiceSummary`, which is
67
66
  often looped and needs the `index` prop to lazy-load everything past the first two instances.
68
67
 
69
68
  ### `headerTag` vs `subheadingTag`
70
69
 
71
- Unlike `ServicesSection` (one `headerTag` prop applied to every heading, title included),
70
+ Unlike `ServiceSummary` (one `headerTag` prop applied to its single title heading),
72
71
  `ServiceDetail` splits these: `headerTag` controls only the hero `<h1>` title; `subheadingTag`
73
72
  controls every other heading (What Is It, Process, Ideal For, Aftercare, FAQs, final CTA heading).
74
73
  This keeps the page's heading hierarchy correct when `ServiceDetail` is the page's own `<h1>`.
@@ -101,7 +100,7 @@ Defaults to a plain, non-linked two-item trail built from `serviceData.category`
101
100
  | `final-cta` | `{ serviceData: Service }` | Button in the closing full-width CTA banner |
102
101
 
103
102
  All routing decisions (breadcrumb links, book-cta href, related-service links, final-cta href)
104
- are delegated to the consumer, matching `ServicesSection`'s slot-based routing pattern.
103
+ are delegated to the consumer, matching `ServiceSummary`'s slot-based routing pattern.
105
104
 
106
105
  ## Usage
107
106
 
@@ -155,10 +154,10 @@ pattern, and `CONSUMER-STYLING.md` in the component's own folder for the full to
155
154
  - Component is auto-imported in Nuxt — no import needed.
156
155
  - The `Service` type is imported from `~/types/types.services`; `BreadcrumbItem` from
157
156
  `~/types/components/breadcrumb`.
158
- - No `isSummary` mode and no content gating — this component always renders everything it's
159
- given. If you need a compact summary card for a services listing page, use
160
- [ServicesSection](services-section.md) in summary mode or [ServicesCard](services-card.md)
161
- instead, and reserve `ServiceDetail` for the individual `/services/[slug]` page.
157
+ - No content gating — this component always renders everything it's given. If you need a compact
158
+ summary card for a services listing page, use [ServiceSummary](service-summary.md) (via
159
+ [ServiceSummaryGrid](service-summary-grid.md)) or [ServicesCard](services-card.md) instead, and
160
+ reserve `ServiceDetail` for the individual `/services/[slug]` page.
162
161
  - Uses [Breadcrumb](breadcrumb.md) internally for the hero banner breadcrumb.
163
162
  - The two-/three-column layout breakpoints (body columns, ideal-for grid, final CTA row) are
164
163
  `@container` queries against the component's own width, not the viewport.
@@ -177,5 +176,5 @@ pattern, and `CONSUMER-STYLING.md` in the component's own folder for the full to
177
176
  carry the full-bleed scrim while its child still lands in the configured column.
178
177
  - The section gets `aria-labelledby` automatically when `tag` is `"section"`, `"article"`, or
179
178
  `"aside"`, pointing at the id `ServiceDetail` binds to its own title `HeroText` internally —
180
- same mechanism as `ServicesSection`. `tag="main"` renders a `<main>` element but is never
179
+ same mechanism as `ServiceSummary`. `tag="main"` renders a `<main>` element but is never
181
180
  auto-labelled.
@@ -0,0 +1,141 @@
1
+ # ServiceSummaryGrid Component
2
+
3
+ ## Overview
4
+
5
+ `ServiceSummaryGrid` renders a vertical stack of [ServiceSummary](service-summary.md) components
6
+ from a `Service[]` array. Column order can be alternated on every other summary via
7
+ `useAlternateReverse`.
8
+
9
+ All routing decisions are delegated to the consumer via the `summary-link` scoped slot — the grid
10
+ forwards `serviceData` from each `ServiceSummary` iteration so the consumer can build hrefs and
11
+ labels freely.
12
+
13
+ This is the renamed successor to `ServicesSectionGrid`, following `ServicesSection`'s rename/
14
+ strip to `ServiceSummary` — see [service-summary.md](service-summary.md) for why the old "full
15
+ mode" content went away.
16
+
17
+ ## Props
18
+
19
+ | Prop | Type | Default | Required |
20
+ |------|------|---------|----------|
21
+ | `servicesData` | `Service[]` | — | **yes** |
22
+ | `tag` | `"div" \| "section" \| "main"` | `"div"` | no |
23
+ | `useAlternateReverse` | `boolean` | `false` | no |
24
+ | `alignment` | `"start" \| "center" \| "end"` | `"center"` | no |
25
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
26
+
27
+ ### `useAlternateReverse`
28
+
29
+ When `true`, odd-indexed summaries flip their image/content column order (`reverse` prop on each
30
+ `ServiceSummary`), creating a visual zigzag layout. Commonly used on services landing pages with
31
+ three or more services.
32
+
33
+ ### `alignment`
34
+
35
+ Passed to every `ServiceSummary` as `alignment` — controls vertical alignment of the info column
36
+ content within the grid cell.
37
+
38
+ ## Slots
39
+
40
+ | Slot | Slot props | Purpose |
41
+ |------|-----------|---------|
42
+ | `summary-link` | `{ serviceData: Service }` | Navigation link below the `whatIsIt` text in each summary |
43
+
44
+ The slot is forwarded from the inner `ServiceSummary` and receives the current iteration's
45
+ `serviceData` as a scoped prop. It is optional — omitting it leaves that area empty.
46
+
47
+ ## CSS custom properties
48
+
49
+ | Token | Default | Controls |
50
+ |---|---|---|
51
+ | `--service-summary-grid-row-gap` | `4rem` | Gap between stacked `ServiceSummary` rows |
52
+
53
+ Each inner `ServiceSummary`'s own tokens (`--service-summary-*`) still apply — see
54
+ `CONSUMER-STYLING.md` in the `service-summary` component folder for the full list.
55
+
56
+ ## Basic usage
57
+
58
+ ```vue
59
+ <ServiceSummaryGrid
60
+ :services-data="servicesData ?? []"
61
+ :use-alternate-reverse="true"
62
+ tag="section"
63
+ >
64
+ <template #summary-link="{ serviceData }">
65
+ <LinkText
66
+ :to="`/services/${serviceData.slug}`"
67
+ :link-text="`More about ${serviceData.title}`"
68
+ :style-class-passthrough="['mb-20']"
69
+ />
70
+ </template>
71
+ </ServiceSummaryGrid>
72
+ ```
73
+
74
+ ## Consumer page boilerplate
75
+
76
+ ```vue
77
+ <template>
78
+ <div>
79
+ <NuxtLayout name="default">
80
+ <template #layout-content>
81
+ <PageRow tag="div" variant="content" :style-class-passthrough="['mbe-20']">
82
+ <h1 class="page-heading-1">Our Services</h1>
83
+ </PageRow>
84
+
85
+ <PageRow tag="div" variant="content" :style-class-passthrough="['mbe-20']">
86
+ <ServiceSummaryGrid
87
+ :services-data="servicesData ?? []"
88
+ :use-alternate-reverse="true"
89
+ tag="section"
90
+ >
91
+ <template #summary-link="{ serviceData }">
92
+ <LinkText
93
+ :to="`/services/${serviceData.slug}`"
94
+ :link-text="`More about ${serviceData.title}`"
95
+ :style-class-passthrough="['mb-20']"
96
+ />
97
+ </template>
98
+ </ServiceSummaryGrid>
99
+ </PageRow>
100
+ </template>
101
+ </NuxtLayout>
102
+ </div>
103
+ </template>
104
+
105
+ <script setup lang="ts">
106
+ definePageMeta({ layout: false });
107
+
108
+ useHead({
109
+ title: "Services",
110
+ meta: [{ name: "description", content: "Browse our services" }],
111
+ bodyAttrs: { class: "page-services" },
112
+ });
113
+
114
+ const store = useServicesStore();
115
+ const { servicesData } = storeToRefs(store);
116
+
117
+ if (servicesData.value.length === 0) {
118
+ await store.fetchServicesData();
119
+ }
120
+ </script>
121
+
122
+ <style lang="css">
123
+ .page-services {
124
+ .service-summary-grid {
125
+ --service-summary-grid-row-gap: 6rem;
126
+ }
127
+ }
128
+ </style>
129
+ ```
130
+
131
+ ## Notes
132
+
133
+ - Component is auto-imported in Nuxt — no import needed.
134
+ - The `Service` type is imported from `~/types/types.services`.
135
+ - Each `ServiceSummary` receives its array `index` for eager/lazy image loading decisions (first
136
+ two summaries load eagerly).
137
+ - Pass `servicesData ?? []` as a safe fallback while data loads asynchronously.
138
+ - If a single page needs a per-item entry animation (e.g. wrapping each item in `EntryAnimation`
139
+ for a scroll-in effect), render `ServiceSummary` directly in your own `v-for` loop instead of
140
+ using this grid wrapper — `ServiceSummaryGrid` has no slot for wrapping individual items.
141
+ - See [service-summary.md](service-summary.md) for the full `ServiceSummary` prop and slot API.
@@ -0,0 +1,165 @@
1
+ # ServiceSummary Component
2
+
3
+ ## Overview
4
+
5
+ `ServiceSummary` renders a single service as a compact two-column preview: image on one side,
6
+ eyebrow/title/price-duration pills/`whatIsIt` text on the other, with a `summary-link` slot for
7
+ navigating to the full service page. All routing decisions are delegated to the consumer via the
8
+ slot.
9
+
10
+ This is a renamed, stripped-down successor to `ServicesSection` — `ServicesSection` used to have
11
+ a second "full mode" (`isSummary: false`) with process/ideal-for/maintenance/FAQ/CTA content, but
12
+ that content shape is now [ServiceDetail](service-detail.md)'s job (a dedicated full-page
13
+ component). `ServiceSummary` only ever renders the compact preview; there is no `isSummary` prop
14
+ because there is no other mode.
15
+
16
+ Usually consumed via [ServiceSummaryGrid](service-summary-grid.md) rather than directly.
17
+
18
+ ## Props
19
+
20
+ | Prop | Type | Default | Required |
21
+ |------|------|---------|----------|
22
+ | `serviceData` | `Service` | — | **yes** |
23
+ | `tag` | `"div" \| "section" \| "article" \| "main"` | `"div"` | no |
24
+ | `headerTag` | `"h1" \| "h2" \| "h3"` | `"h2"` | no |
25
+ | `index` | `number` | `0` | no |
26
+ | `alignment` | `"start" \| "center" \| "end"` | `"center"` | no |
27
+ | `reverse` | `boolean` | `false` | no |
28
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
29
+
30
+ ### `index` and image loading
31
+
32
+ The `index` prop controls both eager/lazy loading and fetch priority. The first two summaries
33
+ (`index` 0 and 1) load eagerly; all others load lazily. Only `index 0` gets `fetchpriority="high"`
34
+ (LCP candidate). Pass the loop index when rendering a list.
35
+
36
+ ### `reverse`
37
+
38
+ Flips the image to the right column and content to the left (CSS `order: 2` on the image
39
+ wrapper).
40
+
41
+ ### `alignment`
42
+
43
+ Vertical alignment of the info column's content within the grid cell — useful when the image is
44
+ taller than the text content and you want the eyebrow/title/pills/link block anchored to the top,
45
+ center, or bottom of that space rather than stretching.
46
+
47
+ ## Slots
48
+
49
+ | Slot | Slot props | Purpose |
50
+ |------|-----------|---------|
51
+ | `summary-link` | `{ serviceData: Service }` | Navigation link below the `whatIsIt` text |
52
+
53
+ ## Duration/price pills
54
+
55
+ Duration and price render as `DisplayPill` instances (`variant="neutral"`, `size="md"`) — the
56
+ same pattern `ServiceDetail`'s hero uses — rather than the icon+text row the old `ServicesSection`
57
+ had. There are no icon-customisation props (`durationIcon`/`priceIcon` are gone along with the
58
+ icon row).
59
+
60
+ ## Row height and image aspect ratio
61
+
62
+ By default `.service-summary` has no fixed height — the row sizes to its content: the image
63
+ sizes itself via `--service-summary-image-aspect-ratio` (default `1 / 1`), and the text column
64
+ sizes to its own content, independently. Set `--service-summary-height-mobile`/`-tablet`/
65
+ `-desktop` (on `.service-summary` itself, via `styleClassPassthrough` or page-scoped CSS — there
66
+ is no prop for this) to force a shared row height instead: both the image (which then crops via
67
+ `object-fit: cover`, and the aspect-ratio token stops having any visible effect) and the text
68
+ column (which then has real leftover space for `alignment` to position within) fill that height.
69
+ See `CONSUMER-STYLING.md` for the full token list and the mobile-stacked-layout caveat.
70
+
71
+ ## Basic usage
72
+
73
+ ```vue
74
+ <ServiceSummary :service-data="service" :index="i">
75
+ <template #summary-link="{ serviceData }">
76
+ <LinkText
77
+ :to="`/services/${serviceData.slug}`"
78
+ :link-text="`More about ${serviceData.title}`"
79
+ :style-class-passthrough="['mb-20']"
80
+ >
81
+ <template #right>
82
+ <Icon name="mdi:arrow-right" />
83
+ </template>
84
+ </LinkText>
85
+ </template>
86
+ </ServiceSummary>
87
+ ```
88
+
89
+ ## Rendering a list
90
+
91
+ Prefer [ServiceSummaryGrid](service-summary-grid.md) for this — it already handles alternating
92
+ `reverse` and index-based image loading. Rendering the loop by hand looks like:
93
+
94
+ ```vue
95
+ <ServiceSummary
96
+ v-for="(service, i) in services"
97
+ :key="service.slug"
98
+ :service-data="service"
99
+ :index="i"
100
+ :reverse="i % 2 !== 0"
101
+ tag="section"
102
+ >
103
+ <template #summary-link="{ serviceData }">
104
+ <LinkText :to="`/services/${serviceData.slug}`" :link-text="`More about ${serviceData.title}`" />
105
+ </template>
106
+ </ServiceSummary>
107
+ ```
108
+
109
+ ## Local style override scaffold
110
+
111
+ When consuming this component, prefer the `--service-summary-*` CSS custom properties documented
112
+ in `CONSUMER-STYLING.md` (in the component's own folder) over raw class overrides. For anything
113
+ the tokens don't cover, scaffold a style block using `styleClassPassthrough`. Delete the block if
114
+ unused.
115
+
116
+ See [component-local-style-override.md](../component-local-style-override.md) for the general
117
+ pattern.
118
+
119
+ ```vue
120
+ <ServiceSummary :style-class-passthrough="['my-summary']" :service-data="service">
121
+ ...
122
+ </ServiceSummary>
123
+
124
+ <style>
125
+ /* ─── ServiceSummary local overrides ──────────────────────────────
126
+ Colours, borders, geometry only — do not override behaviour.
127
+ Delete this block if no overrides are needed.
128
+ ─────────────────────────────────────────────────────────────────── */
129
+ .service-summary {
130
+ &.my-summary {
131
+ --service-summary-image-border-radius: 1.2rem;
132
+ --service-summary-height-tablet: 32rem;
133
+ --service-summary-height-desktop: 40rem;
134
+ --service-summary-image-aspect-ratio: 1 / 1;
135
+ --service-summary-image-padding-block-mobile: 1.2rem;
136
+ --service-summary-image-padding-inline-mobile: 1.2rem;
137
+
138
+ /* Deeper overrides target the BEM element classes directly, e.g.: */
139
+ /* .service-summary__pills { } */
140
+ }
141
+ }
142
+ </style>
143
+ ```
144
+
145
+ ## Notes
146
+
147
+ - Component is auto-imported in Nuxt — no import needed.
148
+ - The `Service` type is imported from `~/types/types.services`.
149
+ - Internal element classes are BEM-namespaced: `service-summary__grid`,
150
+ `service-summary__image-wrapper`, `service-summary__image`, `service-summary__info-wrapper`,
151
+ `service-summary__pills`.
152
+ - The section gets `aria-labelledby` automatically when `tag` is `"section"`, `"article"`, or
153
+ `"aside"`, pointing at the id `ServiceSummary` binds to its own title `HeroText` internally — no
154
+ consumer action needed. (`tag="main"` renders a `<main>` element but is never auto-labelled; see
155
+ [component-aria-landmark.md](../component-aria-landmark.md).)
156
+ - For a full single-service page (process, ideal-for, aftercare, FAQs, booking CTA, related
157
+ services), use [ServiceDetail](service-detail.md) instead — that's the component this one used
158
+ to duplicate in its old "full mode".
159
+ - The two-column breakpoint, the row-height tokens (`--service-summary-height-*`), and the image
160
+ padding tokens (`--service-summary-image-padding-block-*`/`-inline-*`, each `-mobile`/`-tablet`/
161
+ `-desktop`) are all **`@container` queries** against `.service-summary`'s own inline size, not
162
+ `@media` viewport queries — this keeps the layout correct when the component sits next to a
163
+ persistent side nav that shrinks its available width without shrinking the viewport. See
164
+ `CONSUMER-STYLING.md` for the token defaults and fallback
165
+ chain.
@@ -23,7 +23,7 @@ The layer's `modules/icon-sets.ts` runs at dev/build time and logs an info messa
23
23
  | `gravity-ui` | `@iconify-json/gravity-ui` | NavigationItems (burger/ellipsis overflow) |
24
24
  | `ic` | `@iconify-json/ic` | CarouselBasic, CarouselFlip, CarouselInfinite, SliderGallery, CanvasSwitcher |
25
25
  | `lucide` | `@iconify-json/lucide` | ColourFinder, TreatmentConsultant |
26
- | `material-symbols` | `@iconify-json/material-symbols` | ServicesSection, form components |
26
+ | `material-symbols` | `@iconify-json/material-symbols` | form components |
27
27
  | `mdi` | `@iconify-json/mdi` | NavigationHorizontal, form components, ServicesCard |
28
28
  | `radix-icons` | `@iconify-json/radix-icons` | InputPasswordWithLabel, InputError, DisplayThemeSwitch |
29
29
 
@@ -32,6 +32,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
32
32
  ├── theming-override-default.md — replace the entire default theme with a custom palette (set --theme-hue/--theme-chroma)
33
33
  ├── theming-partial-override.md — override a specific colour token category (palette, buttons, inputs) without a full theme replacement
34
34
  ├── theming-form-geometry-tokens.md — non-colour form/button/input tokens (padding, border-radius, gaps): full inventory, why partial override doesn't need duplicating the source files
35
+ ├── theming-component-token-pattern.md — standard shape for overridable component colours: public token + inline fallback, no bare var()/no private-only indirection; the three tiers (theme slots / cross-component families / component-scoped); rollout status
35
36
  ├── colour-scheme-disable.md — disable light/dark scheme support in a consumer app
36
37
  ├── component-dynamic-slots.md — named dynamic slots ($slots iteration) vs indexed dynamic slots (itemCount pattern)
37
38
  ├── component-local-style-override.md — styleClassPassthrough + scoped style block for per-usage visual customisation
@@ -69,8 +70,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
69
70
  ├── page-hero-highlights.md — PageHeroHighlights template: hero + highlights strip grid, CSS custom property theming
70
71
  ├── services-card.md — ServicesCard props (incl. eyebrowConfig/heroConfig), actions slot, CSS tokens, page boilerplate
71
72
  ├── services-card-grid.md — ServicesCardGrid props, config pass-through, CSS tokens, full page boilerplate
72
- ├── services-section-grid.md — ServicesSectionGrid props, useAlternateReverse zigzag layout, page boilerplate
73
- ├── services-section.md — ServicesSection props (incl. heading/CTA copy), summary-link/cta/cta-panel slots, summary vs full mode
73
+ ├── service-summary-grid.md — ServiceSummaryGrid props, useAlternateReverse zigzag layout, page boilerplate
74
+ ├── service-summary.md — ServiceSummary props, summary-link slot, DisplayPill duration/price (renamed/stripped from ServicesSection — full mode is now ServiceDetail)
74
75
  ├── service-detail.md — ServiceDetail: full service detail page (hero banner, sticky sidebar booking card + related services, closing CTA), headerTag vs subheadingTag split, book-cta/sidebar-note/related-service/final-cta slots
75
76
  ├── breadcrumb.md — Breadcrumb: items (BreadcrumbItem[]) trail, link vs current-page text, CSS token API
76
77
  ├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API
@@ -0,0 +1,126 @@
1
+ # Component Token Pattern — Public Token + Inline Fallback
2
+
3
+ ## Overview
4
+
5
+ The standard shape for every overridable CSS value in a component, from now on:
6
+
7
+ ```css
8
+ background-color: var(--{component}-{property}, {real-default-or-shared-fallback});
9
+ ```
10
+
11
+ One name, declared inline at the point of use, with a real default right there in the component
12
+ source. No separate "public token must be declared somewhere else or the component breaks"
13
+ requirement, and no private `--_` indirection standing in for the actual override point.
14
+ `ServicesCard.vue` is the reference implementation — read its `<style>` block for the shape in
15
+ practice.
16
+
17
+ ## Why this replaced the old pattern
18
+
19
+ Two things used to go wrong, both found while auditing `luxury-locs-by-natasha`'s theming
20
+ overrides (2026-08-25):
21
+
22
+ 1. **Bare `var(--theme-checkbox-symbol-color)` with no fallback anywhere.** The only way for this
23
+ to resolve to something was a consuming app declaring it globally. When the component was later
24
+ refactored (buttons moved onto the shared theme-slot system) and stopped reading the token, nothing
25
+ signalled that to the consumer — the app's declaration became permanently dead, silently. A
26
+ `var()` with an undefined custom property and no fallback just resolves to the property's
27
+ initial value; there is no error, no warning, ever.
28
+ 2. **Tokens that were "shared" only by accident.** `InputTextCore`, `InputSelectCore`,
29
+ `InputNumberCore` and the checkbox button all read the exact same `--theme-input-surface`, so a
30
+ consumer wanting to restyle just text inputs had no way to do that without also restyling every
31
+ other input type. The private `--_input-text-surface: var(--theme-input-surface);` indirection
32
+ made this look like it had its own override point when it didn't.
33
+
34
+ ## The three tiers
35
+
36
+ | Tier | Example | No-fallback allowed? |
37
+ |---|---|---|
38
+ | **1 — theme slots** | `--theme-surface`, `--theme-border`, `--theme-ring`, `--theme-text`, ... (`_theme-slots.css`) | Yes — deliberately global, small, stable set. Changing one is meant to reskin everything that shares it. |
39
+ | **2 — cross-component families** | `--theme-input-surface`, `--theme-checkbox-symbol-surface` | No longer created new. Existing ones stay as the *fallback value* inside Tier 3 tokens, not as the thing components read directly. |
40
+ | **3 — component-scoped** | `--input-button-primary-surface`, `--services-card-border-colour` | Never — always `var(--{component}-token, <fallback>)`. |
41
+
42
+ Tier 1 is the one deliberate exception. Everything else gets a real component-prefixed name with
43
+ an inline fallback, even if that fallback happens to be a Tier 1 or Tier 2 token underneath.
44
+
45
+ ## Naming convention
46
+
47
+ `--{component-kebab-name}-{variant-if-any}-{property}[-state]`
48
+
49
+ - `--input-button-primary-surface` / `-surface-hover` — variant + property + state
50
+ - `--services-card-border-colour` / `-border-colour-hover` — property + state
51
+ - Only add a `-hover`/`-focus`/`-active` suffix if that state's value actually differs from the
52
+ base token. If hover reuses the exact same value as base (see `InputButtonCore`'s primary text
53
+ colour, fixed on purpose across states), reuse the base token — don't manufacture a second name
54
+ for a value that never diverges.
55
+
56
+ ## Multi-level fallback chains are fine
57
+
58
+ A component that wants to *look like* another component's default (rather than duplicating a
59
+ raw value) chains through it:
60
+
61
+ ```css
62
+ /* InputCopy's copy button defaults to InputButtonCore's primary look */
63
+ --_button-bg: var(--input-copy-button-bg, var(--input-button-primary-surface, var(--theme-surface)));
64
+ ```
65
+
66
+ Three tiers deep here: instance/global override → "look like the primary button" → the theme
67
+ slot underneath that. Each link in the chain is a real, resolvable token — nothing depends on a
68
+ consuming app having declared anything.
69
+
70
+ ## What this does NOT change
71
+
72
+ - Tier 1 theme slots (`_theme-slots.css`) stay bare `var()`, no fallback, by design.
73
+ - Geometry/typography tokens that already have real component-local defaults
74
+ (`--button-padding-block: 1rem;` in `06.button-geometry.css`) were never part of this problem —
75
+ they're declared with real values already, just not inline at the point of use. Leave them.
76
+
77
+ ## Rollout status
78
+
79
+ - ✅ `InputButtonCore` (primary/secondary/tertiary), `InputCopy`, `PricingCard` — migrated
80
+ 2026-08-25, see their `CONSUMER-STYLING.md` for the full token list. This pass also included a
81
+ deliberate visual redesign (flat borders, `color-mix()`-darkened hover, transparent resting
82
+ outline, separated `:focus-visible`), not just the token API change.
83
+ - ✅ `InputCheckboxRadioCore`/`Button` — migrated 2026-08-25, see their `CONSUMER-STYLING.md`.
84
+ Pure token-rename pass, no visual/default changes (unlike `InputButtonCore`'s redesign above).
85
+ Added Storybook stories for both (`InputCheckboxRadioCore`, `InputCheckboxRadioButton`) —
86
+ neither had one before.
87
+ - ✅ `InputTextCore`, `InputSelectCore`, `InputNumberCore` — migrated 2026-08-25, see their
88
+ `CONSUMER-STYLING.md`. `InputTextCore`/`InputSelectCore` also had this exact problem's original
89
+ form: a private `--_input-text-*`/`--_input-select-*` naming scheme documented as a second
90
+ "local override path" alongside the global tokens, added 2026-08-22 (three days before this
91
+ pattern was formalised) as a workaround for a global-override-not-landing report that was never
92
+ root-caused. That private-var-as-override-point shape is exactly what this pattern replaces —
93
+ renamed public with real fallbacks, old two-path docs removed. `InputNumberCore` had no
94
+ component-scoped tokens at all before this (bare `--theme-input-*` throughout). Added a
95
+ Storybook story for `InputSelectCore` (didn't have one); `InputNumberCore` still doesn't.
96
+ `InputTextCore`/`InputSelectCore`'s `.normal` variant also had hover and `:focus-visible` share
97
+ one token (literally the same rule content duplicated under two selectors) — split into
98
+ `--input-text-border-hover`/`--input-select-border-hover` alongside the existing `-border-focus`
99
+ pair, both still defaulting to `--theme-border-focus` so default rendering is unchanged, but
100
+ mouse and assistive-tech interaction can now be themed independently. `InputNumberCore` has no
101
+ hover state on its border at all (only a `:focus-visible` box-shadow toggle), so there was
102
+ nothing to split there.
103
+ - ✅ `ToggleSwitchCore`, `TripleToggleSwitchCore`, `DisplayThemeSwitch` — migrated 2026-08-25, see
104
+ the first two's `CONSUMER-STYLING.md`. Correction to the two lines this replaces: they claimed
105
+ `ToggleSwitchCore` read a bare `--theme-toggle-symbol-color-*` — that token doesn't exist
106
+ anywhere in this codebase and never did; false memory from conflating it with the checkbox
107
+ fix. `ToggleSwitchCore` itself turned out to be a plain Tier 2 case (real, live tokens, just no
108
+ component-scoped override point) plus five confirmed-dead private locals (declared, never read
109
+ anywhere in the file) removed outright. The real dead-token bug was in `TripleToggleSwitchCore`
110
+ (and duplicated in `DisplayThemeSwitch`'s own override): `--theme-form-radio-border`,
111
+ `--theme-form-radio-outline`, `--theme-form-checkbox-bg` — all three declared nowhere in the
112
+ layer. The first two fed two further-unused private locals (removed); the third fed the
113
+ selected-option marker's background (real bug, narrow impact — only visible when none of the
114
+ component's `system`/`light`/`dark` `:has()` overrides match).
115
+
116
+ Second bug, found while adding the Storybook story `TripleToggleSwitchCore` never had: those
117
+ `:has()` selectors (and the matching `.option-icon.system/.light/.dark` classes, template-bound
118
+ to each option's `id`) said `"auto"`, not `"system"` — but the only known consumer,
119
+ `DisplayThemeSwitch`, has only ever emitted `id`/`value: "system"` (it has to; that literal
120
+ string is `useSettingsStore.setColourScheme`'s type and the CSS class it applies to `<html>`).
121
+ `"auto"` never matched anything real, so the system option's green gradient marker had never
122
+ actually fired — confirmed live before and after the fix. Renamed the selectors to `"system"`
123
+ rather than the data, since the data value is a real external contract this component's
124
+ presentational selectors have no business dictating. `TripleToggleSwitchCore` still hardcodes
125
+ three option values as CSS selectors rather than being genuinely value-agnostic — noted in its
126
+ `CONSUMER-STYLING.md`, left as a larger separate change.
@@ -0,0 +1,67 @@
1
+ {
2
+ "SRCDEV ServiceSummary": {
3
+ "description": "ServiceSummary — compact preview with summary-link slot",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<ServiceSummary",
7
+ " :service-data=\"$1service\"",
8
+ " :index=\"$2index\"",
9
+ " alignment=\"$3center\"",
10
+ ">",
11
+ " <template #summary-link=\"{ serviceData }\">",
12
+ " <NuxtLink :to=\"`/services/${serviceData.slug}`\">$4More about {{ serviceData.title }} →</NuxtLink>",
13
+ " </template>",
14
+ "</ServiceSummary>"
15
+ ]
16
+ },
17
+ "SRCDEV ServiceSummary Reversed": {
18
+ "description": "ServiceSummary with image/content columns swapped",
19
+ "scope": "vue,html",
20
+ "body": [
21
+ "<ServiceSummary :service-data=\"$1service\" :reverse=\"$2true\">",
22
+ " <template #summary-link=\"{ serviceData }\">",
23
+ " <NuxtLink :to=\"`/services/${serviceData.slug}`\">$3More about {{ serviceData.title }} →</NuxtLink>",
24
+ " </template>",
25
+ "</ServiceSummary>"
26
+ ]
27
+ },
28
+ "SRCDEV ServiceSummaryGrid": {
29
+ "description": "ServiceSummaryGrid — alternating zigzag listing from a Service[] array",
30
+ "scope": "vue,html",
31
+ "body": [
32
+ "<ServiceSummaryGrid",
33
+ " :services-data=\"$1servicesData ?? []\"",
34
+ " :use-alternate-reverse=\"$2true\"",
35
+ ">",
36
+ " <template #summary-link=\"{ serviceData }\">",
37
+ " <NuxtLink :to=\"`/services/${serviceData.slug}`\">$3More about {{ serviceData.title }} →</NuxtLink>",
38
+ " </template>",
39
+ "</ServiceSummaryGrid>"
40
+ ]
41
+ },
42
+ "SRCDEV ServiceSummary CSS Override": {
43
+ "description": "CSS override scaffold for ServiceSummary — scope to a page or section class",
44
+ "scope": "css",
45
+ "body": [
46
+ ".$1my-page {",
47
+ " .service-summary {",
48
+ " --service-summary-grid-gap: ;",
49
+ " --service-summary-grid-gap-desktop: ;",
50
+ " --service-summary-image-border-radius: ;",
51
+ " --service-summary-height-mobile: ;",
52
+ " --service-summary-height-tablet: ;",
53
+ " --service-summary-height-desktop: ;",
54
+ " --service-summary-image-aspect-ratio: ;",
55
+ " --service-summary-image-padding-block-mobile: ;",
56
+ " --service-summary-image-padding-inline-mobile: ;",
57
+ " --service-summary-pills-gap: ;",
58
+ " --service-summary-pill-border-colour: ;",
59
+ "",
60
+ " .service-summary__image-wrapper {}",
61
+ " .service-summary__info-wrapper {}",
62
+ " .service-summary__pills {}",
63
+ " }",
64
+ "}"
65
+ ]
66
+ }
67
+ }
@@ -13,5 +13,14 @@
13
13
 
14
14
  --button-icon-gap: 1.8rem;
15
15
 
16
- /* --button-min-height: 4rem; */
16
+ /* Sized so the button's TOTAL rendered height matches --input-min-height, not its content
17
+ box alone — .input-button-core keeps box-sizing: content-box (changing that risks shifting
18
+ icon-only's aspect-ratio: 1 sizing), so its own padding-block and border have to be
19
+ subtracted back out here, unlike --input-min-height itself which happens to need no such
20
+ adjustment (InputTextCore has zero padding-block and no full border in its resting state).
21
+ This is a floor, not a fixed height — icon-only/pill buttons or longer button-text can
22
+ still grow past it. Textareas are the deliberate exception to this alignment entirely. */
23
+ --button-min-height: calc(
24
+ var(--input-min-height) - (2 * var(--button-padding-block)) - (2 * var(--button-border-width))
25
+ );
17
26
  }