srcdev-nuxt-components 9.3.6 → 9.3.8

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 (126) hide show
  1. package/.claude/hooks/check-component-update.sh +30 -1
  2. package/.claude/skills/components/accordian-core.md +7 -7
  3. package/.claude/skills/components/breadcrumb.md +49 -0
  4. package/.claude/skills/components/content-docs.md +2 -1
  5. package/.claude/skills/components/expanding-panel.md +5 -5
  6. package/.claude/skills/components/navigation-items.md +1 -0
  7. package/.claude/skills/components/responsive-header.md +1 -0
  8. package/.claude/skills/components/service-detail.md +156 -0
  9. package/.claude/skills/components/services-card-grid.md +9 -9
  10. package/.claude/skills/components/services-card.md +82 -30
  11. package/.claude/skills/index.md +3 -1
  12. package/.claude/skills/qa-panel.md +2 -2
  13. package/.vscode/srcdev-component-breadcrumb.code-snippets +14 -0
  14. package/.vscode/srcdev-component-service-detail.code-snippets +49 -0
  15. package/.vscode/srcdev-component-services-card.code-snippets +108 -0
  16. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +22 -16
  17. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +94 -0
  18. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +32 -0
  19. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +87 -0
  20. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +87 -0
  21. package/app/components/01.atoms/navigation/breadcrumb/tests/__snapshots__/Breadcrumb.spec.ts.snap +13 -0
  22. package/app/components/02.molecules/expandable/accordian/AccordianCore.vue +5 -1
  23. package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +22 -14
  24. package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +42 -28
  25. package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +8 -10
  26. package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +15 -47
  27. package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +4 -12
  28. package/app/components/03.organisms/responsive-header/NavigationItems.vue +91 -4
  29. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +5 -1
  30. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +2 -2
  31. package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +13 -13
  32. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +13 -13
  33. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +181 -0
  34. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +480 -0
  35. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +282 -0
  36. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +270 -0
  37. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +99 -0
  38. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +129 -0
  39. package/app/components/03.organisms/services/services-card/ServicesCard.vue +153 -39
  40. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +65 -0
  41. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +82 -0
  42. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +13 -5
  43. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +3 -7
  44. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +126 -2
  45. package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +6 -0
  46. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +51 -27
  47. package/app/components/03.organisms/services/services-section/ServicesSection.vue +19 -0
  48. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +8 -8
  49. package/app/types/components/breadcrumb.ts +4 -0
  50. package/app/types/components/index.ts +1 -0
  51. package/package.json +2 -2
  52. package/app/pages/auto-grid.vue +0 -328
  53. package/app/pages/banner-video.vue +0 -238
  54. package/app/pages/forms/examples/buttons/index.vue +0 -259
  55. package/app/pages/forms/examples/material/checkbox-radio-panels.vue +0 -244
  56. package/app/pages/forms/examples/material/cssbattle.vue +0 -60
  57. package/app/pages/forms/examples/material/text-fields.vue +0 -910
  58. package/app/pages/grid-stack.vue +0 -252
  59. package/app/pages/index.vue +0 -127
  60. package/app/pages/page-hero-highlights.vue +0 -204
  61. package/app/pages/samaritan.vue +0 -327
  62. package/app/pages/typography/hero-text.vue +0 -97
  63. package/app/pages/typography/index.vue +0 -39
  64. package/app/pages/typography/page-body.vue +0 -171
  65. package/app/pages/typography/page-heading.vue +0 -76
  66. package/app/pages/typography/page-link.vue +0 -103
  67. package/app/pages/ui/accordian.vue +0 -211
  68. package/app/pages/ui/anchor-scroll-tab-navigation.vue +0 -191
  69. package/app/pages/ui/anchor-scroll.vue +0 -367
  70. package/app/pages/ui/animated-svg-text.vue +0 -85
  71. package/app/pages/ui/block-decorators.vue +0 -142
  72. package/app/pages/ui/card-core.vue +0 -82
  73. package/app/pages/ui/carousel-basic.vue +0 -292
  74. package/app/pages/ui/carousel-flip.vue +0 -449
  75. package/app/pages/ui/carousel-infinite.vue +0 -260
  76. package/app/pages/ui/clipped-panels.vue +0 -101
  77. package/app/pages/ui/contact-section.vue +0 -323
  78. package/app/pages/ui/container-glow.vue +0 -106
  79. package/app/pages/ui/content-width.vue +0 -126
  80. package/app/pages/ui/deep-expanding-menu.vue +0 -138
  81. package/app/pages/ui/display-avatar.vue +0 -253
  82. package/app/pages/ui/display-banner.vue +0 -78
  83. package/app/pages/ui/display-chip.vue +0 -367
  84. package/app/pages/ui/display-details.vue +0 -171
  85. package/app/pages/ui/display-dialog.vue +0 -344
  86. package/app/pages/ui/display-pill.vue +0 -396
  87. package/app/pages/ui/display-prompt.vue +0 -284
  88. package/app/pages/ui/display-toast.vue +0 -311
  89. package/app/pages/ui/expanding-panel.vue +0 -326
  90. package/app/pages/ui/glowing-border.vue +0 -111
  91. package/app/pages/ui/layout-content-docs.vue +0 -72
  92. package/app/pages/ui/layout-grid-a.vue +0 -96
  93. package/app/pages/ui/layout-grid-b.vue +0 -132
  94. package/app/pages/ui/magnetic-navigation.vue +0 -50
  95. package/app/pages/ui/marquee-scroller.vue +0 -99
  96. package/app/pages/ui/mask-element.vue +0 -104
  97. package/app/pages/ui/masonry-columns.vue +0 -55
  98. package/app/pages/ui/masonry-grid-ordered-experiment.vue +0 -79
  99. package/app/pages/ui/masonry-grid-ordered.vue +0 -56
  100. package/app/pages/ui/masonry-grid-sorted.vue +0 -55
  101. package/app/pages/ui/masonry-grid.vue +0 -55
  102. package/app/pages/ui/navigation/navigation-horizontal.vue +0 -484
  103. package/app/pages/ui/navigation/site-navigation/about.vue +0 -36
  104. package/app/pages/ui/navigation/site-navigation/contact.vue +0 -39
  105. package/app/pages/ui/navigation/site-navigation/index.vue +0 -38
  106. package/app/pages/ui/navigation/site-navigation/portfolio.vue +0 -43
  107. package/app/pages/ui/navigation/site-navigation/services.vue +0 -42
  108. package/app/pages/ui/navigation/tab-navigation.vue +0 -493
  109. package/app/pages/ui/page-row.vue +0 -127
  110. package/app/pages/ui/price-list.vue +0 -65
  111. package/app/pages/ui/profile-section.vue +0 -142
  112. package/app/pages/ui/qr-code/[componentName].vue +0 -218
  113. package/app/pages/ui/rotating-carousel.vue +0 -154
  114. package/app/pages/ui/scroll-reveal-image.vue +0 -483
  115. package/app/pages/ui/section-parallax.vue +0 -54
  116. package/app/pages/ui/services/colour-finder.vue +0 -43
  117. package/app/pages/ui/services/services-cards.vue +0 -57
  118. package/app/pages/ui/services/services-section/[slug].vue +0 -61
  119. package/app/pages/ui/services/services-sections-compact.vue +0 -64
  120. package/app/pages/ui/settings.vue +0 -54
  121. package/app/pages/ui/simple-grid.vue +0 -55
  122. package/app/pages/ui/slider-gallery.vue +0 -261
  123. package/app/pages/ui/tabs-y.vue +0 -107
  124. package/app/pages/ui/tabs.vue +0 -189
  125. package/app/pages/ui/tooltips.vue +0 -104
  126. package/app/pages/ui/wipe-away-vertical.vue +0 -129
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # PostToolUse hook (Write|Edit) for app/components/*.vue files. Advisory nudges only
3
3
  # (missing != wrong): skill doc, story, test, CONSUMER-STYLING.md, .vscode snippet,
4
- # legacy tier-folder location, options-style defineProps.
4
+ # legacy tier-folder location, options-style defineProps, single-use private CSS tokens,
5
+ # leftover app/pages/ demo pages (this library is Storybook-only, see Claude.md).
5
6
 
6
7
  root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
7
8
 
@@ -63,4 +64,32 @@ if grep -qE 'defineProps\(\s*\{' "$f" && ! grep -q 'defineProps<' "$f"; then
63
64
  msg="$msg This component uses the options-style defineProps({...}) pattern, an outdated-pattern signal; migrate to interface Props + withDefaults(defineProps<Props>(), {...}) per the Props Pattern in Claude.md."
64
65
  fi
65
66
 
67
+ # Private --_x CSS tokens that appear only twice in the file (once declared, once consumed) are
68
+ # likely pure indirection with no reuse/computation/state to justify them — see the "Public
69
+ # token pattern" rule in Claude.md's Styling Methodology and pitfall #20 (ServicesCard).
70
+ private_vars=$(grep -oE -- '--_[a-zA-Z0-9-]+' "$f" | sort -u)
71
+ if [[ -n "$private_vars" ]]; then
72
+ single_use=""
73
+ while IFS= read -r var; do
74
+ [[ -z "$var" ]] && continue
75
+ count=$(grep -oE -- "\\${var}\\b" "$f" | wc -l | tr -d ' ')
76
+ if [[ "$count" -le 2 ]]; then
77
+ single_use="$single_use $var"
78
+ fi
79
+ done <<< "$private_vars"
80
+ if [[ -n "$single_use" ]]; then
81
+ msg="$msg This component declares private CSS token(s) ($(echo "$single_use" | xargs)) that appear to be used only once — check whether each is genuinely reused across multiple declarations/selectors, composed from a v-bind() value, or swapped by a state (:hover, data-theme). If not, inline the public var(--token, default) directly at its point of use instead, per the Public token pattern rule in Claude.md's Styling Methodology (see pitfall #20 for the ServicesCard precedent), and update CONSUMER-STYLING.md/the skill doc if the token's public name or default changes."
82
+ fi
83
+ fi
84
+
85
+ # This library ships no demo pages — Storybook stories are the only demo surface (see Claude.md's
86
+ # "Storybook is the only demo surface"). Flag any app/pages/ file that mentions this component,
87
+ # whether a pre-existing leftover or a newly (re)added one.
88
+ if [[ -d "$root/app/pages" ]]; then
89
+ demo_pages=$(grep -rlE "\\b${name}\\b" "$root/app/pages" 2>/dev/null | xargs)
90
+ if [[ -n "$demo_pages" ]]; then
91
+ msg="$msg Found file(s) under app/pages/ referencing $name ($demo_pages); this library has no demo pages (Storybook only) — delete them and move any demo-worthy content into the Storybook story instead."
92
+ fi
93
+ fi
94
+
66
95
  jq -n --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
@@ -35,7 +35,7 @@ For `itemCount="3"` the following slots exist:
35
35
  | `name` | `string` | `undefined` | Shared `name` passed to every `ExpandingPanel`. When set, native `<details>` grouping means only one panel can be open at a time. Omit for independent panels. |
36
36
  | `itemCount` | `number` | `0` | Number of `ExpandingPanel` components to render. |
37
37
  | `animationDuration` | `number` | `300` | Expand/collapse animation duration in ms, forwarded to every panel. |
38
- | `variant` | `"modern" \| "classic"` | `"modern"` | `"modern"` renders each panel as `ExpandingPanel` (`::details-content`-based, Baseline "newly available" Sept 2025 the animation itself only runs where `interpolate-size` is supported, Chromium only as of 2026). `"classic"` renders `ExpandingPanelClassic` instead (`grid-template-rows`-based, animates identically in every browser). Applies to every panel in the group — mixing variants within one `AccordianCore` isn't supported. See [expanding-panel-classic.md](expanding-panel-classic.md). |
38
+ | `variant` | `"modern" \| "classic"` | `"classic"` | `"classic"` renders each panel as `ExpandingPanelClassic` (`grid-template-rows`-based, animates identically in every browser). `"modern"` renders `ExpandingPanel` instead (`::details-content`-based) **known not to work correctly on WebKit (Safari/iOS)**, confirmed via real-device testing (2026-08); the animation silently fails and, worse, `contentIsOnTop` positioning never applies there. `"modern"` is opt-in for consumers who've confirmed it works for their audience, not the safe default. Applies to every panel in the group — mixing variants within one `AccordianCore` isn't supported. See [expanding-panel-classic.md](expanding-panel-classic.md) and CLAUDE.md pitfall #19. |
39
39
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.display-accordian` element. |
40
40
 
41
41
  ---
@@ -91,10 +91,10 @@ Passing `name="faq"` groups all panels so only one can be open at a time.
91
91
  </AccordianCore>
92
92
  ```
93
93
 
94
- ### Classic variant (animation must run in every browser)
94
+ ### Modern variant (opt-in, not WebKit-safe)
95
95
 
96
96
  ```vue
97
- <AccordianCore name="faq" :item-count="2" variant="classic">
97
+ <AccordianCore name="faq" :item-count="2" variant="modern">
98
98
  <template #accordian-0-summary><span>Question one?</span></template>
99
99
  <template #accordian-0-content><p>Answer one.</p></template>
100
100
 
@@ -154,10 +154,10 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
154
154
  /* Geometry */
155
155
  /* max-width: none; */ /* default is 600px — remove the width cap */
156
156
 
157
- /* Panel-level overrides via the .accordian-item hook — .expanding-panel is the
158
- root class rendered by the default "modern" variant; swap in
159
- .expanding-panel-classic if this instance uses variant="classic" */
160
- .accordian-item.expanding-panel {
157
+ /* Panel-level overrides via the .accordian-item hook — .expanding-panel-classic is
158
+ the root class rendered by the default "classic" variant; swap in .expanding-panel
159
+ if this instance uses variant="modern" */
160
+ .accordian-item.expanding-panel-classic {
161
161
  /* Border */
162
162
  /* border-block-end: 1px solid currentColor; */
163
163
 
@@ -0,0 +1,49 @@
1
+ # Breadcrumb Component
2
+
3
+ ## Overview
4
+
5
+ `Breadcrumb` renders a `<nav aria-label="Breadcrumb"><ol>...</ol></nav>` trail from an `items`
6
+ array. Routing is delegated to the consumer: an item with a `to` renders as a `NuxtLink`; an item
7
+ without one renders as plain text marked `aria-current="page"` (typically the last item, the
8
+ current page).
9
+
10
+ ## Props
11
+
12
+ | Prop | Type | Default | Required |
13
+ |------|------|---------|----------|
14
+ | `items` | `BreadcrumbItem[]` | — | **yes** |
15
+ | `separator` | `string` | `"/"` | no |
16
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
17
+
18
+ `BreadcrumbItem` is exported from `~/types/components/breadcrumb` (and re-exported from
19
+ `~/types/components`):
20
+
21
+ ```typescript
22
+ interface BreadcrumbItem {
23
+ label: string;
24
+ to?: string;
25
+ }
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```vue
31
+ <Breadcrumb
32
+ :items="[
33
+ { label: 'Services', to: '/services' },
34
+ { label: 'Balayage' },
35
+ ]"
36
+ />
37
+ ```
38
+
39
+ Omit `to` on whichever item represents the current page — usually the last one. Every item with
40
+ a `to` is a real link; the library never guesses which item is "current" from its position.
41
+
42
+ ## Notes
43
+
44
+ - Component is auto-imported in Nuxt — no import needed.
45
+ - No default content/slot — it's data-driven purely from `items`.
46
+ - `ServiceDetail` uses this component internally for its hero banner breadcrumb, overriding
47
+ `--breadcrumb-colour` to `white` so it reads over the hero image. See
48
+ [service-detail.md](service-detail.md).
49
+ - See `CONSUMER-STYLING.md` in the component's own folder for the full `--breadcrumb-*` token API.
@@ -17,6 +17,7 @@
17
17
  | `docsPageNavItems` | `DocsNavItem[]` | `[]` | Items rendered in the `docsPageNav` panel. Panel omitted entirely when empty. |
18
18
  | `docsNavLabel` | `string` | `"Navigation"` | Heading text for the `docsNav` panel's `#summary`. |
19
19
  | `docsPageNavLabel` | `string` | `"On this page"` | Heading text for the `docsPageNav` panel's `#summary`. |
20
+ | `panelVariant` | `"modern" \| "classic"` | `"classic"` | Which panel component renders `docsNav`/`docsPageNav`: `ExpandingPanel` (`"modern"`) or `ExpandingPanelClassic` (`"classic"`, default). `"modern"` is known not to work correctly on WebKit — the mobile nav (which relies on `contentIsOnTop`) becomes unreachable there. See CLAUDE.md pitfall #19. |
20
21
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.content-docs` element. |
21
22
 
22
23
  `DocsNavItem` (from `~/types/components`):
@@ -177,4 +178,4 @@ All `--content-docs-*` tokens can be overridden at global, page, or instance sco
177
178
  - File: `app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue`
178
179
  - Types: `app/types/components/content-docs.d.ts` (`DocsNavItem`)
179
180
  - Tests: `app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts`
180
- - Demo page: `app/pages/ui/layout-content-docs.vue`
181
+ - Storybook: `app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts`
@@ -4,9 +4,9 @@
4
4
 
5
5
  `ExpandingPanel` is a single expand/collapse panel built on the native `<details>`/`<summary>` element. It animates open/close by styling the `::details-content` pseudo-element (the browser's own anonymous box wrapping everything after `<summary>`) where supported, supports `v-model` for controlled state, and can be locked open with `forceOpened`. Multiple panels can be grouped into a native accordion by sharing the same `name` prop (see `AccordianCore`).
6
6
 
7
- **WebKit (Safari/iOS) does not support `::details-content`** as of 2026-08, confirmed via real-device testing — Chrome's device-emulation mode cannot catch this, since it's still the Blink engine underneath regardless of which browser UI it's simulating. WebKit silently drops the whole rule block: no console warning, no error, just no animation and (more seriously) `content-is-on-top`'s `position: absolute` never applying, so an overlay panel renders in normal document flow and can break the surrounding layout. Fixed 2026-08-22: `ExpandingPanel` now carries an `@supports not selector(::details-content)` fallback block that reproduces `ExpandingPanelClassic`'s `grid-template-rows` technique automatically no consumer action needed, this works transparently on every browser. See pitfall #19 in the repo's `CLAUDE.md` for the full mechanism.
7
+ **WebKit (Safari/iOS) does not support `::details-content`** as of 2026-08, confirmed via real-device testing — Chrome's device-emulation mode cannot catch this, since it's still the Blink engine underneath regardless of which browser UI it's simulating. WebKit silently drops the whole rule block: no animation and (more seriously) `content-is-on-top`'s `position: absolute` never applying, so an overlay panel renders in normal document flow and can break the surrounding layout. An `@supports not selector(::details-content)` CSS-only fallback was tried (2026-08-22) but didn't reliably kick in on real devices either and was reverted the same day **do not reintroduce that approach**.
8
8
 
9
- `ExpandingPanelClassic` still exists as a standalone optionsame props/model/slots API, always uses the `grid-template-rows` implementation with no pseudo-element dependency at all but there's no longer a compatibility reason to prefer it over `ExpandingPanel`.
9
+ **The actual fix is component-level, not CSS-level**: every consumer that gave `ExpandingPanel` a choice of implementation now defaults to `ExpandingPanelClassic` instead see `AccordianCore`'s `variant` prop (default `"classic"`), `ContentDocs`'s `panelVariant` prop (default `"classic"`), and `ResponsiveHeader`/`NavigationItems`'s `panelVariant` prop (default `"classic"`). `ExpandingPanel` itself is unchanged (still `::details-content`-only, no fallback) and logs a `console.warn` in dev mode on every mount warning about this. Reach for `ExpandingPanel` directly, or pass `variant`/`panelVariant: "modern"` to one of the above, only once you've confirmed it's acceptable for your actual audience — it is **not** the safe default anywhere in this library anymore. See pitfall #19 in the repo's `CLAUDE.md` for the full history.
10
10
 
11
11
  ---
12
12
 
@@ -145,9 +145,9 @@ Always supply a meaningful `name` prop when using multiple panels on the same pa
145
145
 
146
146
  ## Styling the content when contentIsOnTop
147
147
 
148
- `background-color`, `padding`, and a shadow can be set directly on `.expanding-panel-content` (or on a wrapper inside the `#content` slot — either works) in browsers that support `::details-content`: the clipping boundary there is `::details-content` itself (`overflow: clip`, animated `height`), and `.expanding-panel-content` is a *child* of that box, so its own box is correctly clipped regardless of where the styling lives.
148
+ `background-color`, `padding`, and a shadow can be set directly on `.expanding-panel-content` (or on a wrapper inside the `#content` slot — either works): the clipping boundary is `::details-content` itself (`overflow: clip`, animated `height`), and `.expanding-panel-content` is a *child* of that box, so its own box is correctly clipped regardless of where the styling lives.
149
149
 
150
- In the WebKit fallback path (no `::details-content` support), `.expanding-panel-content` itself is the animated grid track (`grid-template-rows: 0fr → 1fr`) and remains the element being sized — so styling placed directly on it is still clipped correctly there too. The one structural difference: the fallback wraps the `#content` slot in an internal `.inner` div (needed so a plain child's `min-height: auto` doesn't stop the row collapsing to true `0`) — this is purely an implementation detail for the collapse mechanism and isn't a styling hook; don't target `.inner` directly, style `.expanding-panel-content` or the slot's own markup instead, same as the `::details-content` path.
150
+ If this needs to work correctly on WebKit too, use `ExpandingPanelClassic` instead (see the Overview section above) — its equivalent guidance lives in its own `CONSUMER-STYLING.md`, and its `.expanding-panel-classic-content > .inner` structure differs slightly.
151
151
 
152
152
  ---
153
153
 
@@ -213,7 +213,7 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
213
213
 
214
214
  ## Notes
215
215
 
216
- - The open/close animation targets `::details-content` (`height: 0 → auto`, `overflow: clip`) where supported — no JS height measurement needed. Requires `interpolate-size: allow-keywords` for the height transition itself (Chromium only as of 2026; other browsers with `::details-content` support still toggle correctly, just instantly). WebKit has neither, and falls back to `ExpandingPanelClassic`'s `grid-template-rows` technique automatically (see the Overview section above) that path animates in every browser regardless of `interpolate-size`.
216
+ - The open/close animation targets `::details-content` (`height: 0 → auto`, `overflow: clip`) — no JS height measurement needed. Requires `interpolate-size: allow-keywords` for the height transition itself (Chromium only as of 2026; other browsers with `::details-content` support still toggle correctly, just instantly). WebKit has neither, and there is no automatic fallback — use `ExpandingPanelClassic` (see the Overview section above) for anything that needs to work correctly there.
217
217
  - `content-is-on-top` is applied to the root `.expanding-panel` element, not `.expanding-panel-content` — style overrides must scope through it, e.g. `.expanding-panel.my-panel .expanding-panel-content { ... }`.
218
218
  - When `forceOpened` is `true`, `open` stays `true` regardless of `v-model`, but `v-model` still updates internally on clicks (useful if you later set `forceOpened` back to `false`).
219
219
  - Group panels into a native accordion (only one open at a time) by passing the same `name` to multiple panels or use `AccordianCore` which handles this automatically.
@@ -16,6 +16,7 @@ active-route highlighting, hover indicator).
16
16
  | Prop | Type | Default | Notes |
17
17
  |------|------|---------|-------|
18
18
  | `mainNavigationState` | `ResponsiveHeaderState` | `{ clonedNavLinks: {}, navListVisibility: {}, hasSecondNav: false }` | Shared geometry/visibility state, normally supplied by the parent `ResponsiveHeader` — not something a consumer constructs by hand outside of tests/stories. |
19
+ | `panelVariant` | `"modern" \| "classic"` | `"classic"` | Which panel component renders a dropdown's submenu: `ExpandingPanel` (`"modern"`) or `ExpandingPanelClassic` (`"classic"`, default). `"modern"` is known not to work correctly on WebKit. Normally set via `ResponsiveHeader`'s own `panelVariant` prop rather than directly. See CLAUDE.md pitfall #19. |
19
20
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.overflow-navigation-wrapper` element. |
20
21
 
21
22
  ---
@@ -106,6 +106,7 @@ buggy toggle-based version too.
106
106
  | `collapseBreakpoint` | `number \| null` | `null` | A fixed pixel width below which the whole main nav collapses into the overflow burger, instead of the default per-item responsive collapse. |
107
107
  | `collapseAtMainNavIntersection` | `boolean` | `false` | Like `collapseBreakpoint`, but the breakpoint is derived automatically from the main nav's own measured width rather than a fixed number. |
108
108
  | `allowExpandOnGesture` | `boolean` | `true` | When `true`, hovering/focusing a dropdown summary opens it (in addition to click). When `false`, only click toggles it. |
109
+ | `panelVariant` | `"modern" \| "classic"` | `"classic"` | Forwarded to `NavigationItems` for its dropdown submenu panels: `ExpandingPanel` (`"modern"`) or `ExpandingPanelClassic` (`"classic"`, default). `"modern"` is known not to work correctly on WebKit. See CLAUDE.md pitfall #19. |
109
110
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.navigation` element. |
110
111
 
111
112
  ## Slots
@@ -0,0 +1,156 @@
1
+ # ServiceDetail Component
2
+
3
+ ## Overview
4
+
5
+ `ServiceDetail` renders a single service as a full, standalone detail page: a full-bleed hero
6
+ banner (breadcrumb, eyebrow, title, price/duration pills over the service image), a two-column
7
+ body (long-form content in the main column, a sticky sidebar booking card + "You may also like"
8
+ related-services list), and a closing full-width CTA banner. The process steps reuse
9
+ [StepperList](stepper-list.md) (numbered circle indicators with connectors) rather than bespoke
10
+ markup — Ideal For and FAQs are still bespoke (2-column card grid, plain Q&A list) since neither
11
+ matches an existing molecule as directly.
12
+
13
+ It is a different shape from [ServicesSection](services-section.md): `ServicesSection` renders
14
+ image-beside-content (used both as a compact summary card in a list and, in full mode, as a
15
+ same-page detail block); `ServiceDetail` is meant to *be* the whole page for one service —
16
+ `headerTag` defaults to `"h1"` accordingly, and there is no summary/compact mode. There is no
17
+ `isSummary` prop and no `v-if` gating of content — everything the component renders is always
18
+ rendered.
19
+
20
+ ## Props
21
+
22
+ | Prop | Type | Default | Required |
23
+ |------|------|---------|----------|
24
+ | `serviceData` | `Service` | — | **yes** |
25
+ | `tag` | `"div" \| "section" \| "article" \| "main"` | `"div"` | no |
26
+ | `headerTag` | `"h1" \| "h2" \| "h3"` | `"h1"` | no |
27
+ | `subheadingTag` | `"h2" \| "h3"` | `"h2"` | no |
28
+ | `breadcrumbItems` | `BreadcrumbItem[]` | auto-built from `serviceData` | no |
29
+ | `processHeading` | `string` | `"The Process"` | no |
30
+ | `idealForHeading` | `string` | `"Ideal For"` | no |
31
+ | `maintenanceHeading` | `string` | `"Aftercare & Maintenance"` | no |
32
+ | `faqsHeading` | `string` | `"Frequently Asked Questions"` | no |
33
+ | `bookingHeading` | `string` | `"Book This Service"` | no |
34
+ | `priceLabel` | `string` | `"Price"` | no |
35
+ | `durationLabel` | `string` | `"Duration"` | no |
36
+ | `locationLabel` | `string` | `"Location"` | no |
37
+ | `location` | `string` | `undefined` | no |
38
+ | `relatedServicesHeading` | `string` | `"You May Also Like"` | no |
39
+ | `relatedServices` | `Service[]` | `[]` | no |
40
+ | `finalCtaHeading` | `string` | `"Ready to book your appointment?"` | no |
41
+ | `finalCtaBody` | `string` | `"Get in touch to book your appointment."` | no |
42
+ | `imageLoading` | `"eager" \| "lazy"` | `"eager"` | no |
43
+ | `imageFetchPriority` | `"high" \| "auto" \| "low"` | `"high"` | no |
44
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
45
+
46
+ `location` and `relatedServices` are **props on ServiceDetail, not fields on the shared `Service`
47
+ type** — neither is universal enough to belong on `~/types/types.services`. The location row and
48
+ the whole "You may also like" block simply don't render when they're omitted.
49
+
50
+ `imageLoading`/`imageFetchPriority` default to eager/high because a `ServiceDetail` page normally
51
+ has exactly one hero image and it's always the LCP candidate — unlike `ServicesSection`, which is
52
+ often looped and needs the `index` prop to lazy-load everything past the first two instances.
53
+
54
+ ### `headerTag` vs `subheadingTag`
55
+
56
+ Unlike `ServicesSection` (one `headerTag` prop applied to every heading, title included),
57
+ `ServiceDetail` splits these: `headerTag` controls only the hero `<h1>` title; `subheadingTag`
58
+ controls every other heading (What Is It, Process, Ideal For, Aftercare, FAQs, final CTA heading).
59
+ This keeps the page's heading hierarchy correct when `ServiceDetail` is the page's own `<h1>`.
60
+
61
+ The FAQ question itself is a hardcoded `<h3>` — if you set `subheadingTag="h3"`, FAQ questions
62
+ end up at the same level as the FAQs section heading rather than one below it.
63
+
64
+ ### `breadcrumbItems`
65
+
66
+ Defaults to a plain, non-linked two-item trail built from `serviceData.category` and
67
+ `serviceData.title` when omitted. Pass real routes once your app's URL structure is known:
68
+
69
+ ```vue
70
+ <ServiceDetail
71
+ :service-data="service"
72
+ :breadcrumb-items="[
73
+ { label: 'Services', to: '/services' },
74
+ { label: service.title },
75
+ ]"
76
+ />
77
+ ```
78
+
79
+ ## Slots
80
+
81
+ | Slot | Slot props | Purpose |
82
+ |------|-----------|---------|
83
+ | `book-cta` | `{ serviceData: Service }` | Booking button inside the sidebar's booking card |
84
+ | `sidebar-note` | — | Freeform note under the booking button (e.g. patch-test wording) — empty by default, deliberately not hardcoded since this library also serves non-hair-and-beauty apps |
85
+ | `related-service` | `{ service: Service, index: number }` | Replaces one related-service item's whole default (non-clickable) thumbnail/title/price markup — use to wrap it in a real link |
86
+ | `final-cta` | `{ serviceData: Service }` | Button in the closing full-width CTA banner |
87
+
88
+ All routing decisions (breadcrumb links, book-cta href, related-service links, final-cta href)
89
+ are delegated to the consumer, matching `ServicesSection`'s slot-based routing pattern.
90
+
91
+ ## Usage
92
+
93
+ ```vue
94
+ <ServiceDetail
95
+ :service-data="service"
96
+ location="Mobile — across Bath"
97
+ :related-services="relatedServices"
98
+ >
99
+ <template #book-cta>
100
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
101
+ </template>
102
+ <template #sidebar-note>
103
+ A patch test is required at least 48 hours before any colour treatment.
104
+ </template>
105
+ <template #related-service="{ service: related }">
106
+ <NuxtLink :to="`/services/${related.slug}`">{{ related.title }} — {{ related.price }}</NuxtLink>
107
+ </template>
108
+ <template #final-cta>
109
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="secondary" />
110
+ </template>
111
+ </ServiceDetail>
112
+ ```
113
+
114
+ ## Local style override scaffold
115
+
116
+ ```vue
117
+ <ServiceDetail :style-class-passthrough="['my-service-page']" :service-data="service">
118
+ ...
119
+ </ServiceDetail>
120
+
121
+ <style>
122
+ /* ─── ServiceDetail local overrides ──────────────────────────────
123
+ Colours, borders, geometry only — do not override behaviour.
124
+ Delete this block if no overrides are needed.
125
+ ─────────────────────────────────────────────────────────────────── */
126
+ .service-detail {
127
+ &.my-service-page {
128
+ --service-detail-hero-border-radius: 1.2rem;
129
+ --service-detail-process-index-colour: var(--brand-accent);
130
+ }
131
+ }
132
+ </style>
133
+ ```
134
+
135
+ See [component-local-style-override.md](../component-local-style-override.md) for the general
136
+ pattern, and `CONSUMER-STYLING.md` in the component's own folder for the full token API.
137
+
138
+ ## Notes
139
+
140
+ - Component is auto-imported in Nuxt — no import needed.
141
+ - The `Service` type is imported from `~/types/types.services`; `BreadcrumbItem` from
142
+ `~/types/components/breadcrumb`.
143
+ - No `isSummary` mode and no content gating — this component always renders everything it's
144
+ given. If you need a compact summary card for a services listing page, use
145
+ [ServicesSection](services-section.md) in summary mode or [ServicesCard](services-card.md)
146
+ instead, and reserve `ServiceDetail` for the individual `/services/[slug]` page.
147
+ - Uses [Breadcrumb](breadcrumb.md) internally for the hero banner breadcrumb.
148
+ - The two-/three-column layout breakpoints (body columns, ideal-for grid, final CTA row) are
149
+ `@container` queries against the component's own width, not the viewport.
150
+ - Internal element classes are BEM-namespaced under `service-detail__*` (`service-detail__hero`,
151
+ `service-detail__process`, `service-detail__sidebar`, `service-detail__related`, etc.) — see
152
+ `CONSUMER-STYLING.md` for the full list alongside their tokens.
153
+ - The section gets `aria-labelledby` automatically when `tag` is `"section"`, `"article"`, or
154
+ `"aside"`, pointing at the id `ServiceDetail` binds to its own title `HeroText` internally —
155
+ same mechanism as `ServicesSection`. `tag="main"` renders a `<main>` element but is never
156
+ auto-labelled.
@@ -12,7 +12,7 @@
12
12
  | `tag` | `"div" \| "section" \| "main"` | `"div"` | no |
13
13
  | `eyebrowConfig` | `EyebrowConfig` | `{}` | no |
14
14
  | `heroConfig` | `HeroConfig` | `{}` | no |
15
- | `hrefBase` | `string` | `"/ui/services/services-section/"` | no |
15
+ | `hrefBase` | `string` | `"/services/"` | no |
16
16
  | `buttonTextPrefix` | `string` | `"Enquire about"` | no |
17
17
  | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
18
18
 
@@ -38,8 +38,8 @@ Set on `.services-card-grid` (or scoped to a page class):
38
38
 
39
39
  | Token | Default | Controls |
40
40
  | ---------------------- | --------- | ------------------------------------- |
41
- | `--_gap` | `4rem` | Gap between grid cells |
42
- | `--_column-min-width` | `250px` | Minimum column width before wrapping |
41
+ | `--services-card-grid-gap` | `4rem` | Gap between grid cells |
42
+ | `--services-card-grid-column-min-width` | `250px` | Minimum column width before wrapping |
43
43
 
44
44
  ## Consumer page boilerplate
45
45
 
@@ -87,13 +87,13 @@ if (servicesData.value.length === 0) {
87
87
  .page-services {
88
88
  /* Page-level CSS token overrides — delete any you don't need */
89
89
  .services-card-grid {
90
- --_gap: 4rem;
91
- --_column-min-width: 250px;
90
+ --services-card-grid-gap: 4rem;
91
+ --services-card-grid-column-min-width: 250px;
92
92
 
93
93
  .services-card {
94
- --_eyebrow-text-margin-block: 0.8rem 0;
95
- --_hero-text-margin-block: 2rem 1rem;
96
- --_description-text-colour: var(--colour-text-secondary);
94
+ --eyebrow-text-padding-block: 0.8rem 0;
95
+ --hero-text-padding-block: 2rem 1rem;
96
+ --description-text-colour: var(--colour-text-secondary);
97
97
  }
98
98
  }
99
99
  }
@@ -104,6 +104,6 @@ if (servicesData.value.length === 0) {
104
104
 
105
105
  - Component is auto-imported in Nuxt — no import needed.
106
106
  - The `Service` type is imported from `~/types/types.services`.
107
- - Uses `repeat(auto-fit, minmax(var(--_column-min-width), 1fr))` — columns grow to fill available space and wrap when below the minimum width.
107
+ - Uses `repeat(auto-fit, minmax(var(--services-card-grid-column-min-width, 250px), 1fr))` — columns grow to fill available space and wrap when below the minimum width.
108
108
  - The `#actions` slot template is passed down into each `ServicesCard`; `serviceData` is the scoped prop for the current iteration item.
109
109
  - Data fetching is the page's responsibility — pass an empty array as fallback while loading (`servicesData ?? []`).
@@ -2,51 +2,69 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- `ServicesCard` renders a single service as a portrait card: image, subtitle (eyebrow), title, short description, and an `actions` slot for any CTA content. The component owns the layout and data display; all routing and button decisions are delegated to the consumer via the slot.
5
+ `ServicesCard` renders a single service as a portrait card: image, subtitle (eyebrow), title, short description, an optional duration/price meta row, and an `actions` slot for any CTA content. The component owns the layout and data display; all routing and button decisions are delegated to the consumer via the slot.
6
6
 
7
7
  ## Props
8
8
 
9
- | Prop | Type | Default | Required |
10
- | ----------------------- | --------------------------------- | ------- | -------- |
11
- | `serviceData` | `Service` | — | **yes** |
12
- | `tag` | `"div" \| "section" \| "article"` | `"div"` | no |
13
- | `eyebrowConfig` | `EyebrowConfig` | `{}` | no |
14
- | `heroConfig` | `HeroConfig` | `{}` | no |
15
- | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
9
+ | Prop | Type | Default | Required |
10
+ | ----------------------- | ---------------------------------- | ----------- | -------- |
11
+ | `serviceData` | `Service` | — | **yes** |
12
+ | `tag` | `"div" \| "section" \| "article"` | `"div"` | no |
13
+ | `href` | `string` | `undefined` | no |
14
+ | `external` | `boolean` | `false` | no |
15
+ | `eyebrowConfig` | `EyebrowConfig` | `{}` | no |
16
+ | `heroConfig` | `HeroConfig` | `{}` | no |
17
+ | `durationText` | `string` | `undefined` | no |
18
+ | `priceText` | `string` | `undefined` | no |
19
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
20
+
21
+ ### Meta row (`durationText` / `priceText`)
22
+
23
+ A row below the description shows duration (left) and price (right), separated by a top divider. It defaults to `serviceData.duration`/`serviceData.price` — `durationText`/`priceText` props override that text, and the `duration`/`price` slots (see Slots below) fully replace the content (e.g. to add an icon). The row is omitted entirely when there's no duration/price text and no slot content on either side.
24
+
25
+ ### Whole-card clickable (`href`)
26
+
27
+ When `href` is set **and no `actions` slot content is provided**, the root element switches from `tag` to a link (`NuxtLink` for an internal href starting with `/`, a plain `a` otherwise) and the whole card becomes clickable — matching `InputButtonCore`'s link-resolution pattern. `external` forces a plain `a` tag even for an internal-looking href (e.g. a Nitro server route like `/api/auth/github`).
28
+
29
+ If an `actions` slot is provided, the card stays as a static `tag` element (no href rendered) even when `href` is set — this avoids nesting another interactive element (e.g. a button/link in `actions`) inside the card's own anchor, which would be invalid HTML. Use the `href` mode for a plain "whole card links out" card with no separate CTA, and the `actions`-slot mode (see Basic usage below) when the card needs its own button/link.
30
+
31
+ `is-clickable` is added to the root class list when whole-card-clickable mode is active, giving `cursor: pointer` and removing default link colour/underline. It's also what gates the border/outline hover/focus interaction-state tokens — see "Interaction states (whole-card-clickable only)" in `CONSUMER-STYLING.md`; they're a no-op when the card isn't clickable, since only `.is-clickable` has a `:hover`/`:focus-visible` rule.
16
32
 
17
33
  ### EyebrowConfig
18
34
 
19
- | Key | Type | Default |
20
- | ---------- | --------------------------------- | ---------- |
21
- | `tag` | `"p" \| "div" \| "span"` | `"div"` |
22
- | `fontSize` | `"large" \| "medium" \| "small"` | `"large"` |
35
+ | Key | Type | Default |
36
+ | ---------- | ---------------------------------- | --------- |
37
+ | `tag` | `"p" \| "div" \| "span"` | `"div"` |
38
+ | `fontSize` | `"large" \| "medium" \| "small"` | `"large"` |
23
39
 
24
40
  ### HeroConfig
25
41
 
26
- | Key | Type | Default |
27
- | ---------- | -------------------------------------------------------------- | ----------- |
28
- | `tag` | `"h1" \| "h2" \| "h3" \| "h4" \| "h5" \| "h6"` | `"h2"` |
42
+ | Key | Type | Default |
43
+ | ---------- | ----------------------------------------------------------------- | ----------- |
44
+ | `tag` | `"h1" \| "h2" \| "h3" \| "h4" \| "h5" \| "h6"` | `"h2"` |
29
45
  | `fontSize` | `"display" \| "title" \| "heading" \| "subheading" \| "label"` | `"heading"` |
30
46
 
31
47
  Config objects are partial — only specify the keys you want to override. Unset keys fall back to the defaults shown above.
32
48
 
33
49
  ## Slots
34
50
 
35
- | Slot | Slot props | Purpose |
36
- | --------- | -------------------------- | ---------------------------------------------------------------------- |
37
- | `actions` | `{ serviceData: Service }` | CTA area below the description buttons, links, or any action content |
51
+ | Slot | Slot props | Purpose |
52
+ | ---------- | --------------------------- | --------------------------------------------------------------------------------------------------------- |
53
+ | `duration` | `{ serviceData: Service }` | Replaces the duration (left) side of the meta row; defaults to `durationText`/`serviceData.duration` |
54
+ | `price` | `{ serviceData: Service }` | Replaces the price (right) side of the meta row; defaults to `priceText`/`serviceData.price` |
55
+ | `actions` | `{ serviceData: Service }` | CTA area below the meta row — buttons, links, or any action content |
38
56
 
39
- The `actions` slot receives `serviceData` as a scoped prop so the consumer can construct routes or labels from the service data without additional props.
57
+ All three slots receive `serviceData` as a scoped prop so the consumer can construct routes, labels, or formatted text from the service data without additional props.
40
58
 
41
59
  ## CSS custom properties
42
60
 
43
- Set on `.services-card` (or scoped to a page class):
44
-
45
- | Token | Default | Controls |
46
- | ------------------------------ | ----------------------------- | ------------------------------- |
47
- | `--_eyebrow-text-margin-block` | `0.8rem 0` | Space above/below the eyebrow |
48
- | `--_hero-text-margin-block` | `2rem 1rem` | Space above/below the title |
49
- | `--_description-text-colour` | `var(--colour-text-secondary)` | Description paragraph colour |
61
+ Prefer the `--services-card-*`/`--image-wrapper-*`/`--details-wrapper-*`/`--description-*`/`--meta-*`/`--footer-*`
62
+ CSS custom properties documented in `CONSUMER-STYLING.md` (in the component's own folder) over
63
+ raw class overrides — it lists every token, its default, which ones have a global `:root`
64
+ fallback vs. page-scoped-only, and worked examples for global theming, page-scoped overrides,
65
+ and per-instance overrides via `styleClassPassthrough`. For anything the tokens don't cover,
66
+ scaffold a style block using `styleClassPassthrough` instead see
67
+ [component-local-style-override.md](../component-local-style-override.md) for the general pattern.
50
68
 
51
69
  ## Basic usage
52
70
 
@@ -80,6 +98,24 @@ Set on `.services-card` (or scoped to a page class):
80
98
  </ServicesCard>
81
99
  ```
82
100
 
101
+ ## With custom meta row content
102
+
103
+ ```vue
104
+ <ServicesCard :service-data="service">
105
+ <template #duration="{ serviceData }">
106
+ <Icon name="mdi:clock-time-four-outline" class="icon" />
107
+ <span>{{ serviceData.duration }}</span>
108
+ </template>
109
+ <template #price="{ serviceData }">
110
+ <Icon name="mdi:currency-gbp" class="icon" />
111
+ <span>From {{ serviceData.price }}</span>
112
+ </template>
113
+ <template #actions="{ serviceData }">
114
+ <InputButtonCore variant="secondary" :button-text="`Enquire`" :href="`/services/${serviceData.slug}`" />
115
+ </template>
116
+ </ServicesCard>
117
+ ```
118
+
83
119
  ## Consumer page boilerplate
84
120
 
85
121
  ```vue
@@ -107,18 +143,34 @@ Set on `.services-card` (or scoped to a page class):
107
143
  <style lang="css">
108
144
  .page-my-page {
109
145
  .services-card {
110
- --_eyebrow-text-margin-block: 0.8rem 0;
111
- --_hero-text-margin-block: 2rem 1rem;
112
- --_description-text-colour: var(--colour-text-secondary);
146
+ --services-card-gap: 1.4rem;
147
+ --description-line-clamp: 3;
148
+ --eyebrow-text-padding-block: 0.8rem 0;
149
+ --hero-text-padding-block: 2rem 1rem;
150
+ --meta-border-colour: var(--brand-border);
113
151
  }
114
152
  }
115
153
  </style>
116
154
  ```
117
155
 
156
+ See `CONSUMER-STYLING.md` for the full token list — every token is a plain public custom property
157
+ consumed directly at its point of use (no `--_`-prefixed private indirection layer), so all of
158
+ them work both globally (`:root`) and scoped like the example above.
159
+
160
+ ## Whole-card clickable usage
161
+
162
+ ```vue
163
+ <ServicesCard :service-data="service" :href="`/services/${service.slug}`" />
164
+ ```
165
+
166
+ No `actions` slot — the entire card renders as an anchor (`NuxtLink` for the internal href here) and is clickable anywhere within it.
167
+
118
168
  ## Notes
119
169
 
120
170
  - Component is auto-imported in Nuxt — no import needed.
121
171
  - The `Service` type is imported from `~/types/types.services`.
122
- - Grid rows are sized `auto auto auto 5lh auto` the `5lh` row locks description height so actions align across cards.
172
+ - Root markup is two rows (`grid-template-rows: auto 1fr`): `.image-wrapper`, then a `.details-wrapper` (`display: flex; flex-direction: column`) holding the eyebrow, title, description, and a `.footer` wrapper. Description length is controlled by `--description-line-clamp` (default effectively unclamped) rather than a fixed-height grid row.
173
+ - `.footer` groups the meta row and the `actions` slot and gets `margin-block-start: auto`, pinning them to the bottom of the card. Combined with the root's `1fr` details row, this means when `ServicesCardGrid`'s default `align-items: stretch` makes a card taller than its own content (to match a taller sibling in the same row), the extra height goes to `.footer`'s top margin rather than leaving whitespace below the actions slot — so the meta row and actions/button line up across a row of cards regardless of each card's description length. This does the visual job of CSS subgrid without needing a subgrid chain across `ServicesCard`/`ServicesCardGrid` (which would also break whenever cards in a row don't all render the same optional rows — `.meta` and `actions` are both conditional).
123
174
  - Image has a `3/4` aspect ratio with a subtle scale-on-hover effect.
175
+ - `.services-card`, `.image-wrapper`, `.details-wrapper`, and `.footer` all set `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot button label, since `InputButtonCore`'s `.button-text` is `white-space: nowrap` with no ellipsis — forces its content's min-content width up through the flex/grid chain and widens that one card's grid column in `ServicesCardGrid` wider than its siblings (the image just rides along on the widened column; it isn't the actual cause). If you see one card/column wider than the rest with cut-off content, check for a long unbreakable string in a slot before assuming it's an image sizing issue.
124
176
  - Usually consumed via `ServicesCardGrid` rather than directly.
@@ -48,7 +48,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
48
48
  ├── vercel-node-version.md — .nvmrc pinned to Node 24 is required; without it Vercel uses npm 10 which crashes on versionless optional stubs
49
49
  ├── robots-env-aware.md — @nuxtjs/robots: allow crawling on prod domain only, block on preview/staging via env var
50
50
  ├── new-app-scaffold.md — scaffold a new Nuxt consumer app extending this layer (package.json, nuxt.config, app structure, CLAUDE.md)
51
- ├── qa-panel.md — collapsible panel for toggling component props live on a page (always on for /pages/ui/ demo pages; gate with isDev in consuming apps)
51
+ ├── qa-panel.md — collapsible panel for toggling component props live on a page, for consuming apps' own pages (gate with isDev unless demo-only); this library's own demos live in Storybook
52
52
  ├── pull-request-description.md — produce a PR description as a fenced markdown block from git diff vs main
53
53
  ├── using-component-skills.md — discover and use component skills in consumer apps: where skills land, browsing patterns, workflow for deciding build vs. compose
54
54
  ├── composable-canonical-url.md — useCanonicalUrl: set <link rel="canonical"> from runtimeConfig.public.canonicalHost; layout setup, node types
@@ -71,6 +71,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
71
71
  ├── services-card-grid.md — ServicesCardGrid props, config pass-through, CSS tokens, full page boilerplate
72
72
  ├── services-section-grid.md — ServicesSectionGrid props, useAlternateReverse zigzag layout, page boilerplate
73
73
  ├── services-section.md — ServicesSection props (incl. heading/CTA copy), summary-link/cta/cta-panel slots, summary vs full mode
74
+ ├── 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
+ ├── breadcrumb.md — Breadcrumb: items (BreadcrumbItem[]) trail, link vs current-page text, CSS token API
74
76
  ├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API
75
77
  ├── stepper-list.md — StepperList dynamic slots (item-{n}/indicator-{n}), props, connector behaviour
76
78
  ├── expanding-panel.md — ExpandingPanel v-model, forceOpened, contentIsOnTop overlay mode, slots (summary/icon/content), ARIA wiring, CSS token API