srcdev-nuxt-components 9.5.6 → 9.6.0

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 (113) hide show
  1. package/.claude/skills/component-compliance-checklist.md +6 -2
  2. package/.claude/skills/components/breadcrumb.md +5 -0
  3. package/.claude/skills/components/display-pill.md +2 -0
  4. package/.claude/skills/components/input-button.md +3 -0
  5. package/.claude/skills/components/navigation-items.md +1 -0
  6. package/.claude/skills/components/opening-hours.md +12 -0
  7. package/.claude/skills/components/price-list.md +10 -1
  8. package/.claude/skills/components/responsive-header.md +74 -0
  9. package/.claude/skills/components/service-detail.md +32 -0
  10. package/.claude/skills/components/service-summary.md +8 -0
  11. package/.claude/skills/components/services-card-grid.md +5 -3
  12. package/.claude/skills/components/services-card.md +22 -12
  13. package/.claude/skills/components/site-header.md +2 -0
  14. package/.claude/skills/components/slider-gallery.md +10 -4
  15. package/.claude/skills/components/tab-navigation.md +25 -9
  16. package/.claude/skills/index.md +1 -2
  17. package/.claude/skills/storybook-add-story.md +14 -0
  18. package/.vscode/srcdev-component-responsive-header.code-snippets +13 -1
  19. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  20. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  21. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  22. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  23. package/.vscode/srcdev-component-site-header.code-snippets +3 -1
  24. package/.vscode/srcdev-component-tab-navigation.code-snippets +1 -1
  25. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +3 -0
  26. package/app/components/01.atoms/display-pill/DisplayPill.vue +5 -2
  27. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  28. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  29. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  30. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  31. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +52 -11
  32. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +55 -23
  33. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +176 -257
  34. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +42 -2
  35. package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
  36. package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
  37. package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
  38. package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
  39. package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
  40. package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
  41. package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
  42. package/app/components/02.molecules/price-list/PriceList.vue +32 -9
  43. package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
  44. package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
  45. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
  46. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  47. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  48. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  49. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  50. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  51. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +18 -0
  52. package/app/components/03.organisms/responsive-header/NavigationItems.vue +56 -18
  53. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +106 -31
  54. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +201 -18
  55. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
  56. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
  57. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
  58. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
  59. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
  60. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
  61. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
  62. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
  63. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
  64. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
  65. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
  66. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
  67. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
  68. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
  69. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
  70. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
  71. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  72. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +36 -0
  73. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +131 -6
  74. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
  75. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
  76. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  77. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  78. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  79. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  80. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -3
  81. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  82. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  83. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  84. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  85. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  86. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  87. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  88. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  89. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  90. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  91. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  92. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  93. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  94. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  95. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  96. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  97. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  98. package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
  99. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
  100. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  101. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  102. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  103. package/app/composables/useAnchorScroll.ts +1 -1
  104. package/app/utils/opening-hours.ts +23 -2
  105. package/nuxt.config.ts +1 -1
  106. package/package.json +1 -1
  107. package/.claude/skills/components/site-navigation.md +0 -145
  108. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  109. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  110. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  111. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  112. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  113. package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
@@ -13,7 +13,8 @@ skip silently. Notes marked **Existing component** only apply when migrating.
13
13
 
14
14
  A component passes when its Component Ledger row (`node .claude/component-ledger/build.mjs`, then
15
15
  `.claude/component-ledger/audit.json`) shows `score: 5`, a real tier, and `variants`, `legacy_props`,
16
- `story_args_bug`, `eslint_issues`, `redundant_priv_tokens` and `styling_doc_outdated` all `false`.
16
+ `story_args_bug`, `eslint_issues`, `redundant_priv_tokens`, `styling_doc_outdated` and `missing_stress_story`
17
+ all `false`.
17
18
  The ledger can't see items 9, 10, 11 and 14, so check those by hand.
18
19
 
19
20
  ## The checklist
@@ -131,7 +132,10 @@ The ledger can't see items 9, 10, 11 and 14, so check those by hand.
131
132
  covers ellipsis too (`1` is single-line ellipsis). Default it to `none` unless the layout
132
133
  clearly needs a cap, and never clamp text that must stay readable (legal or attribution text,
133
134
  link text that names its destination). Shape and rules: `theming-component-token-pattern.md`,
134
- "Line-clamp tokens". Add a unit test for any logic fix. A component that takes no data or copy
135
+ "Line-clamp tokens". Give each line-clamp token a Controls-panel select in the stories (set on
136
+ a wrapper `:style`, stripped from the component's args), labelled as a story-only CSS token so
137
+ nobody mistakes it for a prop: `storybook-add-story.md`, "Label token controls as story-only".
138
+ Add a unit test for any logic fix. A component that takes no data or copy
135
139
  (e.g. a pure layout wrapper) still gets one with oversized slot content.
136
140
  7. **Skill doc** — create or update `.claude/skills/components/<component-name>.md`, following the
137
141
  pattern of an existing one. A labelled `<Name>Field` wrapper (or any other wrapper) is documented
@@ -47,4 +47,9 @@ a `to` is a real link; the library never guesses which item is "current" from it
47
47
  - `ServiceDetail` uses this component internally for its hero banner breadcrumb, overriding
48
48
  `--breadcrumb-colour` to `white` so it reads over the hero image. See
49
49
  [service-detail.md](service-detail.md).
50
+ - Items with a blank `label` are skipped (no empty crumb, no dangling separator), and nothing renders when
51
+ none are left, so there is no empty `<nav>` landmark. An empty `separator` renders no separator element.
52
+ Added 2026-10-06.
53
+ - Long labels wrap (unbroken ones too). Set `--breadcrumb-item-line-clamp` (default `none`) to cap each
54
+ label's lines with an ellipsis, e.g. `1` for single-line crumbs. Added 2026-10-06.
50
55
  - See `CONSUMER-STYLING.md` in the component's own folder for the full `--breadcrumb-*` token API.
@@ -98,6 +98,8 @@ Colour resolves as **variant token → base token → variant default**: `--disp
98
98
 
99
99
  ## Notes
100
100
 
101
+ - Since 2026-10-06 a pill never grows past its container: a label too long for the space ends in an ellipsis (it still never wraps).
102
+
101
103
  - `border-radius` defaults to `100vw` — this always collapses to the tightest possible radius on the shorter axis, making it geometry-independent (no magic number needed).
102
104
  - `cursor: default` and `user-select: none` are applied unconditionally. `cursor: pointer` is applied automatically via `:is(button, a)` when `tag` is interactive.
103
105
  - All variant colour pairs use a light-tint background (`--color-01`) with a dark text colour (`--color-09`/`--color-10`) to ensure WCAG AA contrast. The project colour scale runs `00` (lightest) → `10` (darkest) — the opposite of Tailwind.
@@ -66,6 +66,9 @@ Colour tokens are `--input-button-{variant}-{property}` with `--theme-*` fallbac
66
66
  typography are the global `--button-*` tokens. Full reference:
67
67
  `app/components/05.forms/input-button/CONSUMER-STYLING.md`.
68
68
 
69
+ Labels stay on one line by default. Set `--input-button-text-white-space: normal` to let a long
70
+ label wrap inside a narrow container (added 2026-10-06; `ServicesCardGrid` does this for its CTAs).
71
+
69
72
  ## Notes
70
73
 
71
74
  - Before 2026-09-28, `readonly` only set `pointer-events: none`, so keyboard users could still
@@ -18,6 +18,7 @@ active-route highlighting, hover indicator).
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
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. |
20
20
  | `ariaLabel` | `string` | `"Overflow navigation menu"` | aria-label on the root `role="menu"` element — override for localisation. Normally set via `ResponsiveHeader`'s own `overflowMenuAriaLabel` prop rather than directly. |
21
+ | `submenuAriaLabel` | `string` | `"{title} submenu"` | aria-label on each submenu summary; `{title}` is replaced with `childLinksTitle` (or `name`). Normally set via `ResponsiveHeader`'s `submenuAriaLabel`. |
21
22
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.overflow-navigation-wrapper` element. |
22
23
 
23
24
  ---
@@ -78,6 +78,18 @@ earlier than `opens` means the session runs past midnight and is displayed as gi
78
78
  - Grouping compares status, sessions and note; only **consecutive** days (in `week-starts-on`
79
79
  order) group. No wrap-around from Sunday to Monday.
80
80
  - Exceptions don't alter the weekly table; they are listed separately.
81
+ - **Bad data never throws** (since 2026-10-05): a time that isn't a valid `HH:MM` from `00:00` to
82
+ `23:59` (e.g. `"abc"`, `"25:99"`, `"24:00"`) and an exception date that isn't a real
83
+ `YYYY-MM-DD` are shown exactly as written. An invalid `locale` falls back to `en-GB`, and an
84
+ invalid `timeZone` to the browser's own. An exception whose `to` is before its `from` is shown
85
+ the right way round, and duplicate exception dates both render. The checks are the
86
+ auto-imported `parseOpeningTime` and `parseOpeningDate` utils (`app/utils/opening-hours.ts`).
87
+ - A session that closes after midnight (`22:00`–`02:00`) displays as written; schema.org reads
88
+ `closes` before `opens` as the next day.
89
+ - `status: "open"` with no sessions is treated as closed (since 2026-10-05): the row shows
90
+ `closedLabel`, gets `data-status="closed"`, and structured data treats it as closed too.
91
+ - Long text wraps rather than overflowing; notes and exception labels have line-clamp tokens
92
+ (`CONSUMER-STYLING.md`). The `StressTest` story covers the worst-case data.
81
93
  - Today's highlight and past-exception hiding run in `onMounted`, never on the server, so cached
82
94
  HTML can't carry a stale day. Past exceptions are briefly visible before hydration.
83
95
  - **Structured data**: emits `{ "@type": structuredData.type ?? "LocalBusiness", "@id", name,
@@ -4,7 +4,8 @@
4
4
 
5
5
  `PriceList` (`app/components/02.molecules/price-list/`) renders a service/menu-style price list:
6
6
  one or more columns, each with a heading (optional icon) and a `<dl>` of description/price rows.
7
- Columns stack below `48em` and sit two-up above it. Not a SaaS plan comparison, use `PricingCard`
7
+ Columns stack in a narrow container and sit two-up once the component itself is wide enough
8
+ (a container-width grid since 2026-10-05, not a viewport media query). Not a SaaS plan comparison, use `PricingCard`
8
9
  for that.
9
10
 
10
11
  ---
@@ -48,6 +49,8 @@ No slots, no events.
48
49
  - Column titles are `HeroText` headings at `heading-tag`. Prices are plain `<span>`s inside `<dd>`,
49
50
  not headings (before 2026-09-27 each price was an `<h2>`, which flooded the heading outline).
50
51
  - Rows are `<div>`-wrapped `<dt>`/`<dd>` pairs, valid inside `<dl>`.
52
+ - A column with an empty `headingtext` renders no heading at all (no empty `<h2>`); its rows still
53
+ render.
51
54
 
52
55
  ---
53
56
 
@@ -57,6 +60,12 @@ All tokens are `--price-list-*`; full table in `CONSUMER-STYLING.md` next to the
57
60
  Common ones: `--price-list-divider-colour`, `--price-list-divider-opacity`,
58
61
  `--price-list-price-font-size`, `--price-list-heading-margin-block-end`.
59
62
 
63
+ Layout and long data: `--price-list-column-min-inline-size` (`32rem`) and `--price-list-max-columns`
64
+ (`2`) control when columns sit side by side; `--price-list-price-max-inline-size` (`50%`) caps the
65
+ price so a long price wraps instead of crushing the description; `--price-list-description-line-clamp`
66
+ (`none`) caps description lines. Unbroken strings wrap anywhere. The `StressTest` story covers the
67
+ worst-case data.
68
+
60
69
  ---
61
70
 
62
71
  ## Usage
@@ -110,8 +110,82 @@ buggy toggle-based version too.
110
110
  | `mainNavAriaLabel` | `string` | `"Main navigation"` | aria-label on the primary nav landmark — override for localisation. |
111
111
  | `secondaryNavAriaLabel` | `string` | `"Secondary navigation"` | aria-label on the secondary (overflow) nav landmark — override for localisation. |
112
112
  | `overflowMenuAriaLabel` | `string` | `"Overflow navigation menu"` | Forwarded to `NavigationItems`' `ariaLabel` — override for localisation. |
113
+ | `overflowButtonLabel` | `string` | `"More navigation"` | Accessible name of the overflow/burger button, which shows only icons. |
114
+ | `submenuAriaLabel` | `string` | `"{title} submenu"` | aria-label on each dropdown summary, top bar and overflow panel; `{title}` is replaced with `childLinksTitle` (or `name`). Forwarded to `NavigationItems`. |
115
+ | `anchorScrollOffset` | `number \| (() => number)` | `undefined` (0) | Pixels left above a section when a `#anchor` link scrolls to it, e.g. a sticky header's height. Pass a getter to read it at click time. |
113
116
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.navigation` element. |
114
117
 
118
+ ## Same-page anchor links (since 2026-10-06)
119
+
120
+ Any item or child link whose `path` starts with `#` is a same-page section link:
121
+
122
+ - It renders as a plain `<a href="#section">`, not a `NuxtLink`, so Vue Router stays out of the scroll.
123
+ - Clicking it smooth-scrolls to the element with that id (`useAnchorScroll`), leaving
124
+ `anchorScrollOffset` pixels above it, and pushes the hash to the URL. Under
125
+ `prefers-reduced-motion: reduce` the browser's instant jump is used instead.
126
+ - It is active (`li.is-active`, the indicator) by hash, not by route. On load, the URL's hash wins;
127
+ with none, the first anchor item in the data is active. Both the top bar and the overflow menu
128
+ share that state.
129
+ - Clicking one closes any open dropdown and the overflow menu (route links close them by navigating).
130
+
131
+ Route links and anchor links can be mixed in the same data. See the "Same-Page Anchor Links" story.
132
+
133
+ ## Migrating from TabNavigation
134
+
135
+ `ResponsiveHeader` covers everything `TabNavigation` does, and collapses progressively (only the items
136
+ that don't fit move into the overflow menu) instead of all-or-nothing. To switch:
137
+
138
+ | TabNavigation | ResponsiveHeader |
139
+ |---|---|
140
+ | `:nav-item-data="{ main: [...] }"` | `:responsive-nav-links="{ main: [...] }"` |
141
+ | item `text` | item `name` |
142
+ | item `href` (route or `#anchor`) | item `path` (route or `#anchor`, same behaviour) |
143
+ | item `iconName` | item `iconName` |
144
+ | item `isExternal` | drop it: `NuxtLink` treats a full URL as external |
145
+ | item `cssName` | no equivalent; drop it (no consumer styles these classes) |
146
+ | `nav-align="right"` | `--responsive-header-main-nav-justify-content: safe end` (CSS token) |
147
+ | `aria-label` | `main-nav-aria-label` |
148
+ | `open-menu-label` / `close-menu-label` | `overflow-button-label` (one label; the button is a `<summary>`) |
149
+ | `anchor-scroll-offset` | `anchor-scroll-offset` |
150
+ | collapse everything to a burger when items don't fit | default is per-item overflow; add `collapse-at-main-nav-intersection` for the old all-or-nothing burger |
151
+ | `--tab-nav-link-color` | `--responsive-header-link-color` |
152
+ | `--tab-nav-decorator-indicator-color` | `--responsive-nav-decorator-indicator-color` |
153
+ | `--tab-nav-*` panel and burger tokens | the overflow tokens in `CONSUMER-STYLING.md` and NavigationItems' `--overflow-nav-*` |
154
+
155
+ ```vue
156
+ <!-- Before -->
157
+ <TabNavigation nav-align="right" :nav-item-data="{ main: [{ text: 'About', href: '#about' }] }" />
158
+
159
+ <!-- After -->
160
+ <ResponsiveHeader
161
+ :responsive-nav-links="{ main: [{ name: 'About', path: '#about' }] }"
162
+ style="--responsive-header-main-nav-justify-content: safe end"
163
+ />
164
+ ```
165
+
166
+ ## Data edge cases (since 2026-10-05)
167
+
168
+ - A dropdown with no `childLinksTitle` shows its `name` instead (it used to render an empty
169
+ summary labelled "undefined submenu").
170
+ - Child links are keyed by index, so duplicate names are fine.
171
+ - The overflow button appears whenever any item is hidden, whatever the group keys are called.
172
+ It used to read `navListVisibility.firstNav`/`secondNav` only, so a group named anything else
173
+ (e.g. `main`) kept the burger showing even when everything fitted. Empty nav data never shows
174
+ it, and still completes the geometry pass.
175
+ - The top-bar dropdown and the overflow panel are capped in width and height (tokens in
176
+ `CONSUMER-STYLING.md`); long names wrap and long lists scroll inside them.
177
+ - **Fit check (fixed 2026-10-05):** an item is visible when its right edge is at or before the main
178
+ nav's own measured right edge (which already has the secondary nav and gap reserved as
179
+ `margin-inline-end`). It used to compare against the wrapper while adding the gap a second time
180
+ with a strict `<`, so the last item of a flush-right second group (e.g. "Contact") was always
181
+ sent to the overflow menu, however wide the screen.
182
+ - The component copies `responsiveNavLinks` before measuring. It used to write each item's
183
+ `config` straight into the consumer's own nav data object.
184
+ - The `StressTest` story covers all of the above.
185
+ - **Alignment (added 2026-10-05):** `--responsive-header-main-nav-justify-content`
186
+ (default `space-between`) sets how the main nav's groups sit along the bar; use `safe end` /
187
+ `safe center`, never plain `end`/`center`. The `MainNavAlignment` story shows the options.
188
+
115
189
  ## Slots
116
190
 
117
191
  | Slot | Purpose |
@@ -36,6 +36,9 @@ always rendered.
36
36
  | `headerTag` | `"h1" \| "h2" \| "h3"` | `"h1"` | no |
37
37
  | `subheadingTag` | `"h2" \| "h3"` | `"h2"` | no |
38
38
  | `breadcrumbItems` | `BreadcrumbItem[]` | auto-built from `serviceData` | no |
39
+ | `breadcrumbAriaLabel` | `string` | Breadcrumb's default | no |
40
+ | `pricePrefix` | `string` | `"From"` | no |
41
+ | `idealForIcon` | `string` | `"mdi:diamond-stone"` | no |
39
42
  | `heroImageVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"full"` | no |
40
43
  | `heroContentVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"content"` | no |
41
44
  | `bodyVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"content"` | no |
@@ -90,6 +93,35 @@ Defaults to a plain, non-linked two-item trail built from `serviceData.category`
90
93
  />
91
94
  ```
92
95
 
96
+ > **Changed 2026-10-06:** the hero price prefix (`pricePrefix`), breadcrumb label and ideal-for icon are
97
+ > props (the `From` and the icon were hardcoded). Sections with empty data, and headings set to `""`, are
98
+ > left out instead of rendering an orphan heading; empty fields render nothing; with no title, a landmark
99
+ > `tag` drops `aria-labelledby`. FAQ questions are one level below `subheadingTag` (were always `h3`).
100
+ > The hero tokens are now a real minimum height, so a long title grows the hero instead of being clipped.
101
+ > Ideal-for and related cards default to `--theme-surface-subtle` (were dark slate with no text colour).
102
+ > The `final-cta` slot sits in `.service-detail__final-cta-action`, which never shrinks, so long copy can't
103
+ > push its button out of the banner. Sidebar row labels size to their words (up to half the row); values wrap.
104
+
105
+ ## Line-clamp tokens
106
+
107
+ All default to `none` (show everything). `1` is single-line ellipsis; any other number caps the lines
108
+ with an ellipsis on the last. Set them in the consumer's own CSS, e.g.
109
+ `.service-page { --service-detail-hero-title-line-clamp: 3; }`.
110
+
111
+ | Token | Clamps |
112
+ |---|---|
113
+ | `--breadcrumb-item-line-clamp` | Each hero breadcrumb label (Breadcrumb's own token) |
114
+ | `--service-detail-hero-eyebrow-line-clamp` | Hero eyebrow (`subtitle`) |
115
+ | `--service-detail-hero-title-line-clamp` | Hero title |
116
+ | `--service-detail-sidebar-label-line-clamp` | "Book This Service" / "You May Also Like" labels |
117
+ | `--service-detail-sidebar-value-line-clamp` | Sidebar row values (price, duration, location) |
118
+ | `--service-detail-related-title-line-clamp` | Related service titles (default item only, not the `related-service` slot) |
119
+ | `--service-detail-final-cta-heading-line-clamp` | Final CTA heading |
120
+ | `--service-detail-final-cta-body-line-clamp` | Final CTA body |
121
+
122
+ The long-form body sections (description, process, FAQs) have no clamp on purpose: this is the page
123
+ people come to read in full.
124
+
93
125
  ## Slots
94
126
 
95
127
  | Slot | Slot props | Purpose |
@@ -25,6 +25,7 @@ Usually consumed via [ServiceSummaryGrid](service-summary-grid.md) rather than d
25
25
  | `index` | `number` | `0` | no |
26
26
  | `alignment` | `"start" \| "center" \| "end"` | `"center"` | no |
27
27
  | `reverse` | `boolean` | `false` | no |
28
+ | `pricePrefix` | `string` | `"From"` | no |
28
29
  | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
29
30
 
30
31
  ### `index` and image loading
@@ -57,6 +58,13 @@ same pattern `ServiceDetail`'s hero uses — rather than the icon+text row the o
57
58
  had. There are no icon-customisation props (`durationIcon`/`priceIcon` are gone along with the
58
59
  icon row).
59
60
 
61
+ The price pill reads `` `${pricePrefix} ${price}` `` (trimmed): pass a translated `pricePrefix`, or `""`
62
+ for the price alone. A pill is only rendered when its value is non-empty, and the pill row is left out
63
+ when both are empty. Empty `subtitle`, `title` and `whatIsIt` render nothing either; with no title, a
64
+ landmark `tag` drops its `aria-labelledby` (there is no heading to point at). Added 2026-10-06, along
65
+ with `--service-summary-column-min-width` and `--service-summary-body-line-clamp`. Before, the `From`
66
+ prefix was hardcoded and empty fields rendered empty elements, including an empty heading.
67
+
60
68
  ## Row height and image aspect ratio
61
69
 
62
70
  By default `.service-summary` has no fixed height — the row sizes to its content: the image
@@ -40,6 +40,7 @@ Set on `.services-card-grid` (or scoped to a page class):
40
40
  | Token | Default | Controls |
41
41
  | ---------------------- | --------- | ------------------------------------- |
42
42
  | `--services-card-grid-gap` | `4rem` | Gap between grid cells |
43
+ | `--services-card-grid-button-text-white-space` | `normal` | CTA label wrapping; `nowrap` keeps labels on one line |
43
44
  | `--services-card-grid-column-min-width` | `250px` | Minimum column width before wrapping |
44
45
 
45
46
  ## Consumer page boilerplate
@@ -92,9 +93,9 @@ if (servicesData.value.length === 0) {
92
93
  --services-card-grid-column-min-width: 250px;
93
94
 
94
95
  .services-card {
95
- --eyebrow-text-padding-block: 0.8rem 0;
96
- --hero-text-padding-block: 2rem 1rem;
97
- --description-text-colour: var(--colour-text-secondary);
96
+ --services-card-eyebrow-padding-block: 0.8rem 0;
97
+ --services-card-title-padding-block: 2rem 1rem;
98
+ --services-card-description-text-colour: inherit;
98
99
  }
99
100
  }
100
101
  }
@@ -107,4 +108,5 @@ if (servicesData.value.length === 0) {
107
108
  - The `Service` type is imported from `~/types/types.services`.
108
109
  - Uses `repeat(auto-fit, minmax(min(var(--services-card-grid-column-min-width, 250px), 100%), 1fr))` (the `min(…, 100%)` stops a single column overflowing containers narrower than the minimum, fixed 2026-09-28) — columns grow to fill available space and wrap when below the minimum width.
109
110
  - The `#actions` slot template is passed down into each `ServicesCard`; `serviceData` is the scoped prop for the current iteration item.
111
+ - CTA labels (`buttonTextPrefix` + title, trimmed) wrap inside the card since 2026-10-06; before, InputButton's `nowrap` let a long or translated title run past the card edge and get clipped.
110
112
  - Data fetching is the page's responsibility — pass an empty array as fallback while loading (`servicesData ?? []`).
@@ -4,6 +4,16 @@
4
4
 
5
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
+ > **Changed 2026-10-06:** every inner class and card-specific token is now prefixed `services-card-`
8
+ > (`.footer` → `.services-card-footer`, `--meta-font-size` → `--services-card-meta-font-size`,
9
+ > `--image-wrapper-*` → `--services-card-image-*`, and so on; the full old-to-new list is in
10
+ > `CONSUMER-STYLING.md` under State hooks). Bare names like `.footer`, `.description` and
11
+ > `--meta-*` collided with consumer CSS, since the card renders inline. Also: empty `subtitle`,
12
+ > `title` or `shortDescription` no longer render an empty element (no empty heading); long unbroken
13
+ > text wraps; a long duration/price wraps onto its own line; `--services-card-description-line-clamp`
14
+ > defaults to `none`; new `--services-card-meta-gap`; outline hover tokens fall back to the resting ones; a whole-card link now shows a focus ring by default
15
+ > (`--services-card-outline-colour-focus`, falling back to the hover colour then `--theme-ring`).
16
+
7
17
  ## Props
8
18
 
9
19
  | Prop | Type | Default | Required |
@@ -33,11 +43,11 @@ If an `actions` slot is provided, the card stays as a static `tag` element (no h
33
43
 
34
44
  ### Titles over the image (`titlesWithinImageWrapper`)
35
45
 
36
- When `true`, the eyebrow and title move out of `.details-wrapper` and sit over the bottom of the image instead. The image wrapper is a `GridStack`: the image is layer 1, and `.image-wrapper-details` (eyebrow + title) is layer 2. The description, meta row and actions stay below the image.
46
+ When `true`, the eyebrow and title move out of `.services-card-details` and sit over the bottom of the image instead. The image wrapper is a `GridStack`: the image is layer 1, and `.services-card-image-details` (eyebrow + title) is layer 2. The description, meta row and actions stay below the image.
37
47
 
38
- - A gradient scrim behind the text keeps it readable. Its colour comes from `--image-wrapper-details-scrim-colour` (default black), and `--image-wrapper-details-scrim` replaces the whole background (`none` removes it).
39
- - Text colour uses `contrast-color()` against the scrim colour where supported (black or white, whichever reads better), falling back to white. It reads the scrim colour only, not the photo, so if you remove the scrim, set `--image-wrapper-details-text-colour` yourself.
40
- - The eyebrow's gradient fill is turned off over the image; it takes the text colour (or `--image-wrapper-details-eyebrow-text-colour`).
48
+ - A gradient scrim behind the text keeps it readable. Its colour comes from `--services-card-image-details-scrim-colour` (default black), and `--services-card-image-details-scrim` replaces the whole background (`none` removes it).
49
+ - Text colour uses `contrast-color()` against the scrim colour where supported (black or white, whichever reads better), falling back to white. It reads the scrim colour only, not the photo, so if you remove the scrim, set `--services-card-image-details-text-colour` yourself.
50
+ - The eyebrow's gradient fill is turned off over the image; it takes the text colour (or `--services-card-image-details-eyebrow-colour`).
41
51
  - The eyebrow's default `fontSize` is `"medium"` in this mode (vs `"large"` below the image). `eyebrowConfig`/`heroConfig` still apply.
42
52
 
43
53
  See "Titles over the image" in `CONSUMER-STYLING.md` for the token list.
@@ -158,10 +168,10 @@ scaffold a style block using `styleClassPassthrough` instead — see
158
168
  .page-my-page {
159
169
  .services-card {
160
170
  --services-card-gap: 1.4rem;
161
- --description-line-clamp: 3;
162
- --eyebrow-text-padding-block: 0.8rem 0;
163
- --hero-text-padding-block: 2rem 1rem;
164
- --meta-border-colour: var(--brand-border);
171
+ --services-card-description-line-clamp: 3;
172
+ --services-card-eyebrow-padding-block: 0.8rem 0;
173
+ --services-card-title-padding-block: 2rem 1rem;
174
+ --services-card-meta-border-colour: var(--brand-border);
165
175
  }
166
176
  }
167
177
  </style>
@@ -193,9 +203,9 @@ No `actions` slot — the entire card renders as an anchor (`NuxtLink` for the i
193
203
 
194
204
  - Component is auto-imported in Nuxt — no import needed.
195
205
  - The `Service` type is imported from `~/types/types.services`.
196
- - 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.
197
- - `.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).
198
- - Image has a `3/4` aspect ratio with a subtle scale-on-hover effect, triggered by hovering the image only (not the whole card). When the card is a whole-card link styled with its own hover effect (e.g. `--services-card-transform-hover`), set `--image-wrapper-border-image-zoom-transform: none` so the two effects don't stack; the "Dark Overlay Cards" story does this. Keep the zoom for the `actions`-slot layout, where the card itself has no hover state. The hover is on `.image-wrapper`, not the image, so it still fires when the overlaid titles cover the image. `.image-wrapper-details` is `position: relative; z-index: 1` because the image's hover `transform` would otherwise paint over it, and `.image-wrapper` sets `isolation: isolate` so that z-index stays inside the card.
199
- - `.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 `InputButton`'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.
206
+ - Root markup is two rows (`grid-template-rows: auto 1fr`): `.services-card-image-wrapper`, then a `.services-card-details` (`display: flex; flex-direction: column`) holding the eyebrow, title, description, and a `.services-card-footer` wrapper. Description length is controlled by `--services-card-description-line-clamp` (default `none`) rather than a fixed-height grid row.
207
+ - `.services-card-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 `.services-card-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 — `.services-card-meta` and `actions` are both conditional).
208
+ - Image has a `3/4` aspect ratio with a subtle scale-on-hover effect, triggered by hovering the image only (not the whole card). When the card is a whole-card link styled with its own hover effect (e.g. `--services-card-transform-hover`), set `--services-card-image-zoom-transform: none` so the two effects don't stack; the "Dark Overlay Cards" story does this. Keep the zoom for the `actions`-slot layout, where the card itself has no hover state. The hover is on `.services-card-image-wrapper`, not the image, so it still fires when the overlaid titles cover the image. `.services-card-image-details` is `position: relative; z-index: 1` because the image's hover `transform` would otherwise paint over it, and `.services-card-image-wrapper` sets `isolation: isolate` so that z-index stays inside the card.
209
+ - `.services-card`, `.services-card-image-wrapper`, `.services-card-details`, and `.services-card-footer` all set `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot button label, since `InputButton`'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.
200
210
  - Usually consumed via `ServicesCardGrid` rather than directly.
201
211
  - The "Dark Overlay Cards" story shows a full restyle using tokens only: dark panel, flush image, titles over the image, hover lift (`--services-card-transform-hover`), and EyebrowText typography tokens (`--eyebrow-text-font-style`, `-letter-spacing`, etc.) set on the card so they inherit into its eyebrow.
@@ -27,6 +27,8 @@ Branding (logo/wordmark) is entirely consumer-authored via the `#branding` slot
27
27
  | `mainNavAriaLabel` | `string` | `"Main navigation"` | Forwarded to `ResponsiveHeader`'s `mainNavAriaLabel` — override for localisation. |
28
28
  | `secondaryNavAriaLabel` | `string` | `"Secondary navigation"` | Forwarded to `ResponsiveHeader`'s `secondaryNavAriaLabel` — override for localisation. |
29
29
  | `overflowMenuAriaLabel` | `string` | `"Overflow navigation menu"` | Forwarded to `ResponsiveHeader`'s `overflowMenuAriaLabel` — override for localisation. |
30
+ | `overflowButtonLabel` | `string` | `"More navigation"` | Forwarded to `ResponsiveHeader`'s `overflowButtonLabel`, the burger button's accessible name. |
31
+ | `submenuAriaLabel` | `string` | `"{title} submenu"` | Forwarded to `ResponsiveHeader`'s `submenuAriaLabel`; `{title}` is replaced with the item's title. |
30
32
 
31
33
  ---
32
34
 
@@ -5,7 +5,13 @@
5
5
  `SliderGallery` (`03.organisms/image-galleries/slider-gallery`) is a full-screen image carousel:
6
6
  a large slide with staggered text, a thumbnail strip that animates into place, prev/next arrows, a
7
7
  progress bar, and optional auto-advance. It's `position: absolute; inset: 0` at `100svh × 100vw`,
8
- so place it inside a positioned container sized for it (usually a full-viewport hero).
8
+ so place it inside a positioned container sized for it (usually a full-viewport hero). To fill a
9
+ smaller positioned container instead, set `--slider-gallery-width: 100%` and `--slider-gallery-height: 100%`.
10
+
11
+ > **Changed 2026-10-06:** worst-case timings are clamped: `animationDuration` below 0 (or not a
12
+ > finite number) becomes 0, and auto-advance waits at least `max(1000, animationDuration, autoRunInterval)`
13
+ > ms. Before, an `autoRunInterval` at or below `animationDuration` stopped auto-advance after the first
14
+ > slide. Long unbroken text now wraps everywhere, and thumbnail text is clamped to 2 lines by default.
9
15
 
10
16
  > **Changed 2026-09-28:** every inner class is now `slider-gallery-*` (was bare `.list`, `.item`,
11
17
  > `.content`, `.title`, `.thumbnail`, `.arrows`, `.time` and so on, which collided with consumer CSS
@@ -20,8 +26,8 @@ so place it inside a positioned container sized for it (usually a full-viewport
20
26
  |---|---|---|---|
21
27
  | `v-model:gallery-data` | `IGalleryData[]` | required | Slides (see type below) |
22
28
  | `autoRun` | `boolean` | `true` | Auto-advance. Paused while hovered or focused; never runs under `prefers-reduced-motion: reduce` |
23
- | `autoRunInterval` | `number` | `7000` | ms between auto-advances |
24
- | `animationDuration` | `number` | `3000` | ms a transition locks the controls (also drives the progress bar) |
29
+ | `autoRunInterval` | `number` | `7000` | ms between auto-advances. Never shorter than `animationDuration` or 1000ms |
30
+ | `animationDuration` | `number` | `3000` | ms a transition locks the controls (also drives the progress bar). Negative values become 0 |
25
31
  | `ariaLabel` | `string` | `"Image gallery"` | Carousel region label |
26
32
  | `loadingText` | `string` | `"Loading gallery..."` | Loading-state copy |
27
33
  | `seeMoreText` | `string` | `"SEE MORE"` | Link text for slides with an `href` |
@@ -78,7 +84,7 @@ const slides = ref<IGalleryData[]>([
78
84
  ## Styling
79
85
 
80
86
  Public tokens (`--slider-gallery-*`) cover height, z-index, accent (spinner and progress), text
81
- colours, thumbnail size/border/radius/overlay, arrow row position (base, tablet, desktop), arrow size, colours, border, outline (resting, hover and focus) and CTA colours. Full reference:
87
+ width, colours, line clamps for each text element (`--slider-gallery-*-line-clamp`; thumbnails default to 2 lines), thumbnail size/border/radius/overlay, arrow row position (base, tablet, desktop), arrow size, colours, border, outline (resting, hover and focus) and CTA colours. Full reference:
82
88
  `app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md`.
83
89
 
84
90
  ## Notes
@@ -1,5 +1,11 @@
1
1
  # TabNavigation
2
2
 
3
+ > **Deprecated (2026-10-06): use [ResponsiveHeader](responsive-header.md) for new work.** It does
4
+ > everything TabNavigation does, including same-page `#anchor` links with `anchorScrollOffset`, and
5
+ > collapses item by item instead of all-or-nothing. Follow "Migrating from TabNavigation" in that doc.
6
+ > TabNavigation stays until existing consumers have moved, then it will be removed. It logs a
7
+ > dev-mode console warning on mount.
8
+
3
9
  ## Overview
4
10
 
5
11
  A responsive horizontal navigation bar with CSS anchor-positioning indicators, smooth anchor
@@ -41,10 +47,20 @@ interface NavItemData {
41
47
  | `nav-align` | `"left" \| "center" \| "right"` | `"left"` | Positions the tab list within the nav container |
42
48
  | `anchor-scroll-offset` | `number \| (() => number)` | — | Pixel offset subtracted from scroll target; pass a getter so it is re-evaluated at click time |
43
49
  | `aria-label` | `string` | `"Site navigation"` | aria-label on the root `<nav>` landmark — override for localisation |
50
+ | `open-menu-label` | `string` | `"Open navigation menu"` | Burger button label while closed — override for localisation |
51
+ | `close-menu-label` | `string` | `"Close navigation menu"` | Burger button label while open — override for localisation |
44
52
  | `:style-class-passthrough` | `string \| string[]` | `[]` | Extra classes on the root `<nav>` element |
45
53
 
46
54
  ---
47
55
 
56
+ > **Changed 2026-10-06:** the burger labels are props (were hardcoded English). Items with blank `text`
57
+ > are skipped (a link with no text has no accessible name), and nothing renders when none are left.
58
+ > Items are keyed by position, so duplicate or missing `href`s no longer break rendering. The mobile
59
+ > panel's id comes from `useId()` (was a fixed `tab-nav-panel`, which duplicated with two navs on a
60
+ > page). Links show a focus ring (`--tab-nav-focus-ring-*`), and long panel labels wrap. Colour
61
+ > defaults are light-theme now (`--theme-text` links, `--theme-accent` hover and indicator, `--page-bg`
62
+ > panel); they used to assume a dark header. Dark headers or dark mode set the colour tokens.
63
+
48
64
  ## Basic usage — route links
49
65
 
50
66
  ```vue
@@ -146,9 +162,9 @@ the component source folder for the full token reference.
146
162
 
147
163
  | Token | Default | Controls |
148
164
  |---|---|---|
149
- | `--tab-nav-link-color` | `var(--slate-01, currentColor)` | Link text colour |
150
- | `--tab-nav-link-hover-color` | `var(--slate-04, currentColor)` | Link hover colour |
151
- | `--tab-nav-link-active-color` | `var(--slate-01, currentColor)` | Active link colour |
165
+ | `--tab-nav-link-color` | `var(--theme-text)` | Link text colour |
166
+ | `--tab-nav-link-hover-color` | `var(--theme-accent)` | Link hover colour |
167
+ | `--tab-nav-link-active-color` | the link colour | Active link colour |
152
168
  | `--tab-nav-link-size` | `1.6rem` | Font size |
153
169
  | `--tab-nav-link-weight` | `400` | Font weight |
154
170
  | `--tab-nav-link-tracking` | `0.06em` | Letter spacing |
@@ -159,17 +175,17 @@ the component source folder for the full token reference.
159
175
 
160
176
  | Token | Default | Controls |
161
177
  |---|---|---|
162
- | `--tab-nav-decorator-indicator-color` | `var(--slate-01, currentColor)` | Active underline bar colour |
178
+ | `--tab-nav-decorator-indicator-color` | `var(--theme-accent)` | Active underline bar colour |
163
179
  | `--tab-nav-decorator-hovered-bg` | `transparent` | Hover pill background (set non-transparent to enable) |
164
180
 
165
181
  ### Mobile panel
166
182
 
167
183
  | Token | Default | Controls |
168
184
  |---|---|---|
169
- | `--tab-nav-panel-bg` | `var(--page-bg, #1a1614)` | Panel background |
170
- | `--tab-nav-panel-border-color` | `color-mix(in oklch, var(--slate-01) 35%, transparent)` | Panel top border |
171
- | `--tab-nav-panel-item-border` | `color-mix(in oklch, var(--slate-01) 8%, transparent)` | Item dividers |
172
- | `--tab-nav-panel-link-color` | `var(--slate-01, currentColor)` | Panel link colour |
185
+ | `--tab-nav-panel-bg` | `var(--page-bg, var(--theme-surface-subtle))` | Panel background |
186
+ | `--tab-nav-panel-border-color` | the link colour at 20% | Panel top border |
187
+ | `--tab-nav-panel-item-border` | the panel link colour at 12% | Item dividers |
188
+ | `--tab-nav-panel-link-color` | the link colour | Panel link colour |
173
189
  | `--tab-nav-panel-padding-block` | `1.4rem` | Link vertical padding |
174
190
  | `--tab-nav-panel-padding-inline` | `1.5rem` | Link horizontal padding |
175
191
  | `--tab-nav-panel-slide-duration` | `350ms` | Panel open/close duration |
@@ -179,7 +195,7 @@ the component source folder for the full token reference.
179
195
 
180
196
  | Token | Default | Controls |
181
197
  |---|---|---|
182
- | `--tab-nav-burger-color` | `var(--slate-01, currentColor)` | Bar colour |
198
+ | `--tab-nav-burger-color` | the link colour | Bar colour |
183
199
  | `--tab-nav-burger-width` | `22px` | Bar width |
184
200
  | `--tab-nav-burger-height` | `1.5px` | Bar height |
185
201
  | `--tab-nav-burger-gap` | `5px` | Gap between bars |
@@ -107,8 +107,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
107
107
  ├── wipe-away-vertical.md — WipeAwayVertical: scroll-driven vertical wipe-away effect, pure-CSS grid overlay (no JS), indexed dynamic slots, sticky-centering-via-calc gotcha, CSS tokens
108
108
  ├── marquee-scroller.md — MarqueeScroller: infinite logo/badge scroller, per-item dynamic slots (marqueeData id), hover/focus/keyboard pause, reduced-motion, CSS tokens
109
109
  ├── rotating-carousel-image.md — RotatingCarouselImage: 3D rotating image carousel, scroll-parallax tilt, focus/keyboard/hover pause, reduced-motion, CSS tokens
110
- ├── site-navigation.md — SiteNavigation: responsive nav with auto-collapse, burger menu, decorator indicators, CSS token API
111
- ├── tab-navigation.md — TabNavigation: horizontal tab nav with CSS anchor-positioning indicators, anchor scroll, burger collapse, full CSS token API
110
+ ├── tab-navigation.md — TabNavigation (DEPRECATED, use ResponsiveHeader): horizontal tab nav with CSS anchor-positioning indicators, anchor scroll, burger collapse, full CSS token API
112
111
  ├── social-icons-list.md — SocialIconsList: data-driven social icon links, ISocialIcon type, logos: icon names, CSS tokens
113
112
  ├── display-qr-code.md — DisplayQrCode: QR code SVG from a string value, colour/size/variant/radius props, currentColor default
114
113
  ├── capture-qr-code.md — CaptureQrCode: live camera scanner, error state, visibility/route/KeepAlive lifecycle, media stream cleanup
@@ -271,6 +271,20 @@ Key points:
271
271
  - Extra args must be stripped before `v-bind` — spreading unknown keys onto a component makes them unknown HTML attributes.
272
272
  - CSS custom properties set via `:style` on the component root are picked up by `var()` in the component's scoped CSS.
273
273
 
274
+ ### Label token controls as story-only
275
+
276
+ A control that drives a CSS token (a line clamp, a colour) looks exactly like a prop in the
277
+ Controls panel and the generated docs table, so a developer reading the story can assume it's a
278
+ prop and try to pass it. Make it unmistakable:
279
+
280
+ - Put it in the category `"CSS tokens (story only, set in your CSS)"`.
281
+ - Start the description with `**Story control, not a prop.**`, name the token, and show the CSS a
282
+ consumer would write instead, e.g.
283
+ `` `.salon-menu { --price-list-description-line-clamp: 2; }` ``.
284
+ - If a story's docs text tells people to try the control, say there too that it's a CSS token.
285
+
286
+ `PriceList.stories.ts` and `GoogleReviews.stories.ts` (line-clamp controls) follow this.
287
+
274
288
  ## Notes
275
289
 
276
290
  - **Colour scheme:** `.storybook/preview.ts` pins every story to the light scheme
@@ -24,7 +24,9 @@
24
24
  " main-nav-aria-label=\"${2:Main navigation}\"",
25
25
  " secondary-nav-aria-label=\"${3:Secondary navigation}\"",
26
26
  " overflow-menu-aria-label=\"${4:Overflow navigation menu}\"",
27
- "/>"
27
+ " overflow-button-label=\"${5:More navigation}\"",
28
+ " submenu-aria-label=\"${6:{title\\} submenu}\"",
29
+ "></ResponsiveHeader>"
28
30
  ]
29
31
  },
30
32
  "SRCDEV ResponsiveHeader Nav Data (with dropdown)": {
@@ -54,5 +56,15 @@
54
56
  " --responsive-header-link-font-size: ${4:1.4rem};",
55
57
  "}"
56
58
  ]
59
+ },
60
+ "SRCDEV ResponsiveHeader Anchor Links": {
61
+ "description": "ResponsiveHeader for a one-page site: #anchor items smooth-scroll to sections, offset for a sticky header",
62
+ "scope": "vue,html",
63
+ "body": [
64
+ "<ResponsiveHeader",
65
+ " :responsive-nav-links=\"{ main: [{ name: '$1Home', path: '#$2home' }, { name: '$3About', path: '#$4about' }] }\"",
66
+ " :anchor-scroll-offset=\"${5:80}\"",
67
+ "/>"
68
+ ]
57
69
  }
58
70
  }
@@ -7,6 +7,7 @@
7
7
  " :service-data=\"$1service\"",
8
8
  " location=\"$2Mobile — across Bath\"",
9
9
  " :related-services=\"$3relatedServices\"",
10
+ " price-prefix=\"${7:From}\"",
10
11
  ">",
11
12
  " <template #book-cta>",
12
13
  " <button type=\"button\">$4Book Now</button>",
@@ -18,7 +18,7 @@
18
18
  "description": "ServiceSummary with image/content columns swapped",
19
19
  "scope": "vue,html",
20
20
  "body": [
21
- "<ServiceSummary :service-data=\"$1service\" :reverse=\"$2true\">",
21
+ "<ServiceSummary :service-data=\"$1service\" :reverse=\"$2true\" price-prefix=\"${3:From}\">",
22
22
  " <template #summary-link=\"{ serviceData }\">",
23
23
  " <NuxtLink :to=\"`/services/${serviceData.slug}`\">$3More about {{ serviceData.title }} →</NuxtLink>",
24
24
  " </template>",
@@ -55,6 +55,8 @@
55
55
  " --service-summary-image-padding-block-mobile: ;",
56
56
  " --service-summary-image-padding-inline-mobile: ;",
57
57
  " --service-summary-pills-gap: ;",
58
+ " --service-summary-column-min-width: ;",
59
+ " --service-summary-body-line-clamp: ;",
58
60
  " --service-summary-pill-border-colour: ;",
59
61
  "",
60
62
  " .service-summary__image-wrapper {}",