srcdev-nuxt-components 9.6.13 → 9.6.15

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 (74) hide show
  1. package/.claude/rules/consumer/story-register-components.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +5 -0
  3. package/.claude/skills/components/display-qr-code.md +22 -4
  4. package/.claude/skills/components/eyebrow-text.md +9 -5
  5. package/.claude/skills/components/header-block.md +13 -5
  6. package/.claude/skills/components/hero-text.md +13 -9
  7. package/.claude/skills/components/link-text.md +8 -5
  8. package/.claude/skills/components/pop-over.md +17 -3
  9. package/.claude/skills/components/sliding-toggle.md +108 -0
  10. package/.claude/skills/components/tabbed-content.md +8 -3
  11. package/.claude/skills/composable-sliding-indicator.md +60 -0
  12. package/.claude/skills/index.md +2 -0
  13. package/.claude/skills/storybook-add-story.md +22 -2
  14. package/.claude/skills/testing-add-playwright.md +12 -7
  15. package/.claude/skills/testing-add-unit-test.md +25 -0
  16. package/.claude/skills/theming-component-token-pattern.md +18 -0
  17. package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
  18. package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
  19. package/.vscode/srcdev-component-link-text.code-snippets +3 -3
  20. package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
  21. package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
  22. package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
  23. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
  24. package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
  25. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
  26. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
  27. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
  28. package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
  29. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
  30. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
  31. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
  32. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
  33. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
  34. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
  35. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
  36. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
  37. package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
  38. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
  39. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
  40. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
  41. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
  42. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
  43. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
  44. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
  45. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
  46. package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
  47. package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
  48. package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
  49. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
  50. package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
  51. package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
  52. package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
  53. package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
  54. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
  55. package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
  56. package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
  57. package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
  58. package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
  59. package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
  60. package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
  61. package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
  62. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
  63. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
  64. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
  65. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
  66. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
  67. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
  68. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
  69. package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
  70. package/app/composables/useSlidingIndicator.ts +56 -0
  71. package/app/composables/useTabs.ts +4 -31
  72. package/app/types/components/index.ts +1 -0
  73. package/app/types/components/sliding-toggle.d.ts +5 -0
  74. package/package.json +1 -1
@@ -6,7 +6,7 @@ paths:
6
6
  # Register every component a story template uses
7
7
 
8
8
  Every component in a story's `template` string, including ones placed in the component's slots, must
9
- be listed in that story's `components: {}`.
9
+ be listed in that story's `components: {}`. Globally registered ones (`Icon`, `NuxtLink`) are the exception.
10
10
 
11
11
  Why: components aren't auto-registered in story templates; unregistered ones render as unknown tags.
12
12
  Detail: skill `storybook-add-story.md` (srcdev-nuxt-components).
@@ -138,6 +138,11 @@ The ledger can't see items 6c, 9, 10, 11 and 14, so check those by hand.
138
138
  `computed()`: Vue renders nothing for a fractional or negative range and only warns in dev
139
139
  (`StepperList`, 2026-10-15)
140
140
  - broken image or link URLs
141
+ - data a third-party child can't handle: a library that throws while rendering (an encoder, a
142
+ parser) breaks the whole page render. Catch it with `onErrorCaptured` and render a fallback
143
+ (`DisplayQrCode` threw "Data too long" on an over-capacity value until 2026-10-19). Check story
144
+ control ranges against the library's real range too: its `radius` control ran to 20 while the
145
+ library clamps to 0-1
141
146
  Describe what to check in the story's docs description, and look at it at every
142
147
  `CanvasSwitcher` width. Fixes usually mean `overflow-wrap: anywhere`, `min-inline-size: 0` on
143
148
  flex/grid children, `Array.from(str)` instead of `str[0]`, and `clamp()` on values
@@ -10,10 +10,10 @@ Renders a styled QR code SVG from a string value. Supports colour, size, corner
10
10
  |------|------|---------|-------------|
11
11
  | `qr-value` | `string` | required | The string to encode into the QR code |
12
12
  | `variant` | `QrCodeVariant` | `{ inner: "default", marker: "default", pixel: "default" }` | Shape variants for inner markers, outer markers, and pixels |
13
- | `radius` | `number` | `0` | Corner rounding applied to pixels/markers (0–1 scale) |
13
+ | `radius` | `number` | `0` | Corner rounding for the rounded, circle and dots shapes, 0 to 1 (the library clamps values outside) |
14
14
  | `black-color` | `string` | `"currentColor"` | Colour for the dark modules |
15
15
  | `white-color` | `string` | `"transparent"` | Colour for the light modules |
16
- | `size` | `string` | `"256px"` | Width of the QR code (height matches via `aspect-ratio: 1 / 1`) |
16
+ | `size` | `string` | `"256px"` | Width of the QR code (height matches via `aspect-ratio: 1 / 1`), never wider than its container |
17
17
  | `aria-label` | `string` | `"QR code"` | Accessible name for the QR image (root has `role="img"`). Pass something specific and translated, e.g. "Scan to book an appointment". |
18
18
  | `style-class-passthrough` | `string \| string[]` | `[]` | Extra classes on the root element |
19
19
 
@@ -22,7 +22,7 @@ Renders a styled QR code SVG from a string value. Supports colour, size, corner
22
22
  ```typescript
23
23
  import type { QrCodeVariant } from "srcdev-nuxt-components/types/components";
24
24
 
25
- // Each field accepts: "default" | "circle" | "rounded" | "dots" | "classy" | "classy-rounded"
25
+ // Each field accepts: "default" | "circle" | "rounded" | "dots" | "pixelated"
26
26
  const variant: QrCodeVariant = {
27
27
  inner: "circle",
28
28
  marker: "rounded",
@@ -30,6 +30,21 @@ const variant: QrCodeVariant = {
30
30
  };
31
31
  ```
32
32
 
33
+ ## Slots and events
34
+
35
+ | Name | Kind | Description |
36
+ |------|------|-------------|
37
+ | `fallback` | slot | Rendered in place of the code when `qr-value` is too long to encode (about 2.9KB at the lowest error correction, less at higher). Empty by default, leaving a blank box of the same size. |
38
+ | `error` | event | `(error: Error)`, emitted once when encoding fails. Resets when `qr-value` changes. |
39
+
40
+ ```vue
41
+ <DisplayQrCode :qr-value="bookingUrl" @error="reportQrError">
42
+ <template #fallback>
43
+ <a :href="bookingUrl">Open the booking page</a>
44
+ </template>
45
+ </DisplayQrCode>
46
+ ```
47
+
33
48
  ## CSS token
34
49
 
35
50
  `--display-qr-code-size` overrides the `size` prop when set (e.g. on a page wrapper). Full reference: `app/components/01.atoms/qr-code/CONSUMER-STYLING.md`.
@@ -39,6 +54,7 @@ const variant: QrCodeVariant = {
39
54
  | Class | Where | Description |
40
55
  |-------|-------|-------------|
41
56
  | `.display-qr-code` | root | always present |
57
+ | `.display-qr-code-fallback` | root | only when the value can't be encoded (with `data-error`) |
42
58
 
43
59
  ## Usage
44
60
 
@@ -63,7 +79,9 @@ Custom size, colour, and rounded pixels:
63
79
 
64
80
  ## Notes
65
81
 
66
- - The component renders directly as the `Qrcode` element from `nuxt-qrcode` — there is no wrapper `<div>`. The `.display-qr-code` class is applied to the `Qrcode` root, which renders as an `<svg>`.
82
+ - The component renders directly as the `Qrcode` element from `nuxt-qrcode` — there is no wrapper `<div>`. The `.display-qr-code` class is applied to the `Qrcode` root, which renders as an `<svg>`. When encoding fails it renders a `<span class="display-qr-code display-qr-code-fallback">` instead.
83
+ - Encoding errors are caught with `onErrorCaptured`, so an over-capacity value from a CMS can't break the page render. `nuxt-qrcode` must be installed in the consuming app.
67
84
  - `size` is applied via CSS `v-bind()` — pass any valid CSS length value (`px`, `rem`, `%`, etc.).
68
85
  - `black-color` defaults to `currentColor`, so the QR code inherits the surrounding text colour automatically unless overridden.
69
86
  - `white-color` defaults to `transparent`, making the background of the QR code see-through by default.
87
+ - 2026-10-19 migration: added the `fallback` slot and `error` event, capped the width at the container, corrected the variant list (`pixelated`, no `classy`) and added a `StressTest` story; the story's radius control now uses the real 0 to 1 range.
@@ -27,7 +27,7 @@ Do not assume placeholder text or default content.
27
27
  ## Basic Usage
28
28
 
29
29
  ```vue
30
- <EyebrowText textContent="Interior Design" />
30
+ <EyebrowText text-content="Interior Design" />
31
31
  ```
32
32
 
33
33
  ## With Tag and Size
@@ -37,8 +37,8 @@ Use `tag="p"` for semantic paragraph context, or `tag="span"` when inline. Choos
37
37
  ```vue
38
38
  <EyebrowText
39
39
  tag="p"
40
- fontSize="small"
41
- textContent="Featured Collection"
40
+ font-size="small"
41
+ text-content="Featured Collection"
42
42
  />
43
43
  ```
44
44
 
@@ -47,10 +47,10 @@ Use `tag="p"` for semantic paragraph context, or `tag="span"` when inline. Choos
47
47
  EyebrowText is commonly placed directly above a `HeroText` heading:
48
48
 
49
49
  ```vue
50
- <EyebrowText tag="p" textContent="Our Services" />
50
+ <EyebrowText tag="p" text-content="Our Services" />
51
51
  <HeroText
52
52
  tag="h2"
53
- :textContent="[
53
+ :text-content="[
54
54
  { text: 'Crafted with', styleClass: 'normal' },
55
55
  { text: 'Care', styleClass: 'accent' },
56
56
  ]"
@@ -59,6 +59,8 @@ EyebrowText is commonly placed directly above a `HeroText` heading:
59
59
 
60
60
  ## Font Size Scale
61
61
 
62
+ The prop is rendered as `data-font-size` on the root (not a class).
63
+
62
64
  | Value | CSS variable |
63
65
  |-------|-------------|
64
66
  | `large` | `--eyebrow-text-large` (default `1.4rem`) |
@@ -110,3 +112,5 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
110
112
 
111
113
  - Component is auto-imported in Nuxt — no import needed.
112
114
  - `tag` defaults to `"div"` which is block-level. Use `"span"` when the eyebrow must be inline within a flow of text.
115
+ - Long text and unbroken strings wrap (`overflow-wrap: anywhere`). There is no line-clamp token: clamping needs `overflow: hidden`, which clips the italic overhang; `ServiceDetail` clamps its eyebrow itself with an allowance.
116
+ - 2026-10-19 migration: the size moved from a bare `large`/`medium`/`small` class to `data-font-size`; added wrapping, a `StressTest` story and parent-story links.
@@ -58,10 +58,11 @@ heading to have an accessible name. Bind the same id to both:
58
58
  </section>
59
59
  ```
60
60
 
61
- Several components in this library (`PageRow`, `LayoutGridByCols`,
62
- `ProfileSection`, `ServiceSummary`, `ServiceDetail`, `PageHeroHighlights`) already generate this id
63
- automatically via the `useAriaLabelledById` composable and expose it as a `heading-id` slot prop —
64
- when composing inside one of those, bind `HeaderBlock`'s `id` to that slot prop instead of writing
61
+ Three components in this library generate this id with the `useAriaLabelledById` composable and
62
+ expose it as a `heading-id` slot prop: `PageRow` (default slot), `ProfileSection` (`heroText` slot)
63
+ and `PageHeroHighlights` (`header` slot). (`LayoutGridByCols`, `ServiceSummary` and `ServiceDetail`
64
+ use the composable too, but label their own heading or an `sr-only` label, with no slot prop.) When
65
+ composing inside one of the three, bind `HeaderBlock`'s `id` to that slot prop instead of writing
65
66
  your own id:
66
67
 
67
68
  ```vue
@@ -72,7 +73,8 @@ your own id:
72
73
 
73
74
  ## Styling
74
75
 
75
- `HeaderBlock` has no CSS of its own — `classLevel` applies one of the global
76
+ The root always has the `.header-block` class, whose only style is `overflow-wrap: anywhere` (so
77
+ an unbroken word wraps instead of overflowing). `classLevel` applies one of the global
76
78
  `.page-heading-1`–`.page-heading-6` utility classes defined in
77
79
  `app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-heading.css`, each
78
80
  setting `font-size` from a shared fluid type-scale token (`--step-3` through `--step-8`). All six
@@ -90,3 +92,9 @@ Moved to `01.atoms/text-blocks/header-block/`, alongside `EyebrowText`/`HeroText
90
92
  added the `id` prop in the same pass — several sections need their heading to carry a specific id
91
93
  for `aria-labelledby`, and `HeaderBlock` had no explicit way to receive one (matching `PageRow`'s
92
94
  existing `id?: string | null` + `:id` convention).
95
+
96
+ ## Migration note (2026-10-19)
97
+
98
+ Added the `.header-block` root class and `overflow-wrap: anywhere`, a `StressTest` story, a
99
+ story-only slot-text control and docs text. The spec now unmounts every wrapper. Corrected the list
100
+ of components that expose a `heading-id` slot prop.
@@ -45,7 +45,7 @@ interface TextConfig {
45
45
  ```vue
46
46
  <HeroText
47
47
  tag="h1"
48
- :textContent="[
48
+ :text-content="[
49
49
  { text: 'Designing', styleClass: 'normal' },
50
50
  { text: 'Artistry', styleClass: 'accent' },
51
51
  { text: 'at Home', styleClass: 'normal' },
@@ -61,8 +61,8 @@ interface TextConfig {
61
61
  <HeroText
62
62
  tag="h2"
63
63
  icon="lucide:sparkles"
64
- fontSize="heading"
65
- :textContent="[{ text: 'Featured', styleClass: 'accent' }]"
64
+ font-size="heading"
65
+ :text-content="[{ text: 'Featured', styleClass: 'accent' }]"
66
66
  />
67
67
  ```
68
68
 
@@ -74,8 +74,8 @@ Use `axis="vertical"` to stack text segments in a column instead of a row.
74
74
  <HeroText
75
75
  tag="h1"
76
76
  axis="vertical"
77
- fontSize="display"
78
- :textContent="[
77
+ font-size="display"
78
+ :text-content="[
79
79
  { text: 'Modern', styleClass: 'normal' },
80
80
  { text: 'Living', styleClass: 'accent' },
81
81
  ]"
@@ -96,7 +96,9 @@ Define these CSS custom properties in your consuming app to control sizes.
96
96
 
97
97
  ## Styling
98
98
 
99
- Override via `styleClassPassthrough` or a parent HOC `<style>` block targeting `.hero-text`.
99
+ Override via `styleClassPassthrough` or a parent HOC `<style>` block targeting `.hero-text`. Size
100
+ and axis are `data-font-size` and `data-axis` attributes on the root (not classes); each segment is a
101
+ `<span class="hero-text-segment">` plus its `styleClass`.
100
102
 
101
103
  Key CSS custom properties:
102
104
 
@@ -107,7 +109,6 @@ Key CSS custom properties:
107
109
  | `--hero-text-{scale}` | see CONSUMER-STYLING.md | Font size per `fontSize` prop value (the library theme sets them on `:root`; the component has matching fallbacks) |
108
110
  | `--hero-text-font-family` | `"Playfair Display"` | Font family of the whole component |
109
111
  | `--hero-text-margin` | `0` | Margin on the root element |
110
- | `--hero-text-horizontal-gap` | `0.5ch` | Gap between segments in `axis="horizontal"` mode |
111
112
  | `--hero-text-vertical-gap` | `0.4em` | Gap between segments in `axis="vertical"` mode |
112
113
  | `--hero-text-accent-offset` | `0.2em` | Underline-clearance offset on `.accent` spans (`padding-bottom` and the matching negative `margin-bottom`) |
113
114
 
@@ -115,7 +116,7 @@ Key CSS custom properties:
115
116
 
116
117
  ```css
117
118
  .my-page {
118
- .hero-text.axis-vertical {
119
+ .hero-text[data-axis="vertical"] {
119
120
  --hero-text-vertical-gap: 0.6em;
120
121
  }
121
122
  }
@@ -149,6 +150,9 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
149
150
 
150
151
  ## Notes
151
152
 
152
- - Text segments are trimmed and a trailing space is automatically appended between segments in horizontal axis — do not manually pad `text` values.
153
+ - Text segments are trimmed and a trailing space is automatically appended between segments — do not manually pad `text` values. Segments with blank or missing `text` (common in CMS data) are dropped.
154
+ - The icon is decorative (`aria-hidden`).
155
+ - Long text and unbroken strings wrap. No line-clamp token: clamping needs `overflow: hidden`, which clips the accent descender offset; `ServiceDetail` clamps its title itself.
156
+ - 2026-10-19 migration: size and axis moved from bare root classes to `data-font-size`/`data-axis`, segments from `.text-block-{n}` to `.hero-text-segment`; dropped the dead `--hero-text-horizontal-gap`; added a `StressTest` story.
153
157
  - The icon is sized to match the font size. At `subheading` scale it is `0.75em` (applied as `font-size`, since `width`/`height` on an `<Icon>` are overridden by `@nuxt/icon`).
154
158
  - Component is auto-imported in Nuxt — no import needed.
@@ -26,13 +26,13 @@ Both slots are optional. If neither is provided, no icon wrapper elements are re
26
26
  ## Basic Usage
27
27
 
28
28
  ```vue
29
- <LinkText to="/about" linkText="About Us" />
29
+ <LinkText to="/about" link-text="About Us" />
30
30
  ```
31
31
 
32
32
  ## With Left Icon (back/previous pattern)
33
33
 
34
34
  ```vue
35
- <LinkText to="/blog" linkText="Back to Blog">
35
+ <LinkText to="/blog" link-text="Back to Blog">
36
36
  <template #left>
37
37
  <Icon name="lucide:arrow-left" />
38
38
  </template>
@@ -42,7 +42,7 @@ Both slots are optional. If neither is provided, no icon wrapper elements are re
42
42
  ## With Right Icon (forward/more pattern)
43
43
 
44
44
  ```vue
45
- <LinkText to="/services" linkText="Learn More">
45
+ <LinkText to="/services" link-text="Learn More">
46
46
  <template #right>
47
47
  <Icon name="lucide:arrow-right" />
48
48
  </template>
@@ -52,7 +52,7 @@ Both slots are optional. If neither is provided, no icon wrapper elements are re
52
52
  ## Both Icons
53
53
 
54
54
  ```vue
55
- <LinkText to="/collection" linkText="Explore">
55
+ <LinkText to="/collection" link-text="Explore">
56
56
  <template #left>
57
57
  <Icon name="lucide:sparkles" />
58
58
  </template>
@@ -67,7 +67,7 @@ Both slots are optional. If neither is provided, no icon wrapper elements are re
67
67
  ```vue
68
68
  <LinkText
69
69
  to="https://example.com"
70
- linkText="Visit Site"
70
+ link-text="Visit Site"
71
71
  :external="true"
72
72
  target="_blank"
73
73
  >
@@ -132,3 +132,6 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
132
132
  - NuxtLink handles both internal (`/path`) and external (`https://...`) URLs automatically. Only set `external: true` if you need to force external handling on an internal-looking path.
133
133
  - The label is always rendered inside `.link-text__label` — target this class if you need to style the text independently of the icons.
134
134
  - DOM order is always: left icon → label → right icon.
135
+ - Long and unbroken labels wrap inside the column (the root is capped at `max-inline-size: 100%`). There is no line-clamp token: link text names its destination.
136
+ - For an icon-only link, pass an empty `link-text` and an `aria-label`; it falls through to the `<a>`.
137
+ - 2026-10-19 migration: label wrapping, rem focus ring, `StressTest` story, docs text; the spec now unmounts every wrapper.
@@ -28,8 +28,9 @@ and Escape dismissal.
28
28
  |------|------|---------|-------------|
29
29
  | `placement` | `"top" \| "right" \| "bottom" \| "left"` | `"right"` | Which side of the trigger the popover panel opens on. Each side has a `position-try-fallbacks` so the browser flips it automatically if it would overflow the viewport. |
30
30
  | `triggerAriaLabel` | `string` | `""` | aria-label on the trigger button — set this when the `trigger` slot is icon-only. Unset (no attribute) by default, since the trigger slot usually carries its own visible/accessible content. |
31
- | `popoverAriaLabel` | `string` | `""` | aria-label on the popover content region — set this when the `content` slot has no visible heading. |
32
- | `closeButtonAriaLabel` | `string` | `"Close"` | aria-label on the close button — override for localisation. |
31
+ | `popoverAriaLabel` | `string` | `""` | Names the popover panel and gives it `role="dialog"` (with no label it has no role, since a name on a generic element isn't announced) — set this when the `content` slot has no visible heading. |
32
+ | `closeButtonAriaLabel` | `string` | `"Close"` | The close button's accessible name (its `aria-label` and its visually hidden button text) — override for localisation. |
33
+ | `closeIcon` | `string` | `"lucide:x"` | Iconify name for the close button icon. |
33
34
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes applied to the root element. |
34
35
 
35
36
  ## Slots
@@ -80,11 +81,20 @@ the only source of space above/below the slotted content — a plain `<p>` doesn
80
81
  does the same in JS. The trigger has `aria-expanded` in every browser.
81
82
  - On open, focus moves to the close button (the composable's `onOpen`) so keyboard users land
82
83
  inside the panel immediately.
84
+ - The close button is an icon-only `InputButton variant="inline"` with class
85
+ `pop-over-close-button`, pinned to the panel's block-start/inline-end corner, so it sits top left
86
+ in right-to-left text. The content slot's top padding keeps content clear of it.
87
+ - The panel is never wider than the viewport minus `3.2rem` (whatever `--pop-over-width` says) or
88
+ taller than `--pop-over-max-block-size` (default `calc(100dvh - 3.2rem)`); taller content scrolls
89
+ inside the panel. Unbroken strings in the trigger and panel wrap (`overflow-wrap: anywhere`).
83
90
 
84
91
  ## CSS custom properties
85
92
 
86
93
  See `CONSUMER-STYLING.md` for the full `--pop-over-*` token API (gap, trigger outline, panel
87
- border/background/text/shadow/width, content padding, and close-button tokens).
94
+ border/background/text/shadow/width/max-height, content padding, and close-button tokens).
95
+
96
+ No line-clamp tokens: all text arrives through the `trigger` and `content` slots, so the consumer
97
+ clamps it in their own markup.
88
98
 
89
99
  ## Notes
90
100
 
@@ -101,3 +111,7 @@ border/background/text/shadow/width, content padding, and close-button tokens).
101
111
  `@position-try` blocks commented out and unused; promoted every hardcoded value to a public CSS
102
112
  token; added tests, a Storybook story, this skill doc, and a VS Code snippet (all previously
103
113
  missing).
114
+ - 2026-10-19 migration: the close button became an `InputButton variant="inline"` (it was a raw
115
+ `<button>`) and gained a `closeIcon` prop; it now uses logical insets. The panel caps at the
116
+ viewport width and height and wraps unbroken strings; the trigger wraps too. Added a
117
+ `StressTest` story and per-story docs descriptions.
@@ -0,0 +1,108 @@
1
+ ---
2
+ name: SlidingToggle
3
+ description: SlidingToggle single-choice segmented radiogroup with text labels of any width, measured sliding indicator (stretch-then-catch-up) plus hover indicator, corner-shape token, CSS token API
4
+ type: reference
5
+ ---
6
+
7
+ # SlidingToggle
8
+
9
+ ## Overview
10
+
11
+ A single-choice segmented control for short text choices such as a billing period or a site tier.
12
+ The selected option's background slides between options of different widths, with the same
13
+ stretch-then-catch-up motion as `TabbedContent`'s indicator (both use `useSlidingIndicator`), and a
14
+ second indicator follows the pointer. It's a WAI-ARIA radio group of `<button role="radio">`s, not
15
+ a form control: there are no native inputs and nothing is submitted.
16
+
17
+ Lives in `app/components/02.molecules/sliding-toggle/`.
18
+
19
+ **Which one to use:**
20
+
21
+ - `SlidingToggle` — text labels of any width, page UI state (pricing toggles, view switches).
22
+ - `SegmentedControl` (`05.forms`) — icon-only, equal-width options, native radio inputs that submit
23
+ with a form.
24
+ - `TabbedContent` — the choice switches between panels of content.
25
+
26
+ ## Props
27
+
28
+ | Prop (template form) | Type | Default | Notes |
29
+ |---|---|---|---|
30
+ | `v-model` | `T` (`string \| number`) | (required) | The selected option's `value`. A value matching no option leaves nothing selected. |
31
+ | `options` | `SlidingToggleOption<T>[]` | (required) | `{ value, label, disabled? }`. Labels are plain text. |
32
+ | `aria-label` | `string` | `""` | Names the radiogroup. Set this or `aria-labelledby`. |
33
+ | `aria-labelledby` | `string` | `""` | Id of a visible heading naming the group. |
34
+ | `disabled` | `boolean` | `false` | Disables every option. |
35
+ | `track-hover` | `boolean` | `true` | Show the hover indicator. |
36
+ | `transition-duration` | `number` (ms) | `200` | Length of each edge's slide. |
37
+ | `style-class-passthrough` | `string \| string[]` | `[]` | Classes on the root. |
38
+
39
+ `SlidingToggleOption` is exported from `~/types/components`.
40
+
41
+ ## Usage
42
+
43
+ ```vue
44
+ <script setup lang="ts">
45
+ const billing = ref<"monthly" | "yearly">("monthly");
46
+ const billingOptions = [
47
+ { value: "monthly", label: "Monthly" },
48
+ { value: "yearly", label: "Yearly (save 20%)" },
49
+ ] as const;
50
+ </script>
51
+
52
+ <template>
53
+ <SlidingToggle v-model="billing" :options="[...billingOptions]" aria-label="Billing period" />
54
+ </template>
55
+ ```
56
+
57
+ Equal widths across a full-width switch:
58
+
59
+ ```css
60
+ .billing-switch {
61
+ --sliding-toggle-option-flex: 1 1 0;
62
+ }
63
+
64
+ .billing-switch .sliding-toggle {
65
+ inline-size: 100%;
66
+ }
67
+ ```
68
+
69
+ ## Behaviour
70
+
71
+ - The root is `role="radiogroup"`, named by `aria-label` or `aria-labelledby`. Each option is a
72
+ `<button type="button" role="radio">` with `aria-checked`.
73
+ - Clicking an option sets `v-model`; changing `v-model` from outside moves the selection.
74
+ - Only the selected option is a tab stop. With no match, the first enabled option is.
75
+ - Arrow keys move focus and select, wrapping round and skipping disabled options. Right and Down go
76
+ to the next option, Left and Up to the previous; inside `dir="rtl"` Left and Right swap. Home and
77
+ End go to the first and last enabled option.
78
+ - `disabled` on the group or on one option blocks clicks and keys; disabled options are skipped and
79
+ never get the hover indicator.
80
+ - The selected indicator slides to the new option, its leading edge first and the trailing edge one
81
+ `transition-duration` later. The hover indicator follows the pointer and returns to the selected
82
+ option when it leaves; `track-hover="false"` turns it off. `--sliding-toggle-indicator-stretch: 0`
83
+ makes both indicators slide their edges together instead of stretching, mirroring TabbedContent's
84
+ `--tabs-indicator-stretch`.
85
+ - Under `prefers-reduced-motion` both indicators snap.
86
+ - Before it's mounted and measured (SSR, first paint) the indicators are hidden and the selected
87
+ option paints its own background and marker, so there's no flash of an unselected group.
88
+ - It re-measures when the track or any single option resizes (a late web font changes label widths
89
+ without resizing the track) and when options are added, removed or re-classed.
90
+ - Long, unbroken and many labels wrap and shrink inside the track instead of overflowing.
91
+
92
+ ## CSS custom properties
93
+
94
+ See `CONSUMER-STYLING.md` for the full `--sliding-toggle-*` token API: track surface, padding, gap,
95
+ radii, `--sliding-toggle-corner-shape`, indicator and hover-indicator surfaces, marker, option
96
+ padding/flex/typography/colours and the focus ring. Colours default from `--theme-*` tokens.
97
+
98
+ `--sliding-toggle-corner-shape` takes any `corner-shape` value (`squircle`, `superellipse(n)`,
99
+ `bevel`, `scoop`, `notch`). It's a progressive enhancement: Chromium 139+ only, and other browsers
100
+ keep the `border-radius` corners.
101
+
102
+ ## Notes
103
+
104
+ - Auto-imported in Nuxt.
105
+ - The motion comes from the `useSlidingIndicator` composable, shared with `TabbedContent` (via
106
+ `useTabs`). It writes `--_sliding-toggle-active-*` / `--_sliding-toggle-hover-*` on the root.
107
+ - Added 2026-10-19, generalised from a consumer app's pricing-page toggle (which used `aria-pressed`
108
+ buttons; this version uses radio semantics).
@@ -137,6 +137,10 @@ source order when the list changes.
137
137
 
138
138
  ## Indicator motion: edge insets (2026-10-08)
139
139
 
140
+ > **Changed 2026-10-19**: the edge maths moved into `useSlidingIndicator` (`composable-sliding-indicator.md`),
141
+ > shared with `SlidingToggle`. `useTabs.placeIndicator` now calls it and keeps the label-coverage
142
+ > tracking. Under `prefers-reduced-motion` the indicators now snap instead of sliding.
143
+
140
144
  `useTabs.placeIndicator(kind, animate)` writes each indicator's two edges as insets from the
141
145
  bar's start/end along the axis (`--_{kind}-start` / `--_{kind}-end`), and the CSS transitions
142
146
  `left`/`right` (or `top`/`bottom`) separately. Moving forward, the start edge gets a delay of one
@@ -145,9 +149,10 @@ and the trailing edge catches up: the same stretch effect as before, symmetric b
145
149
  settle timer. CSS retargets each edge from its current position, so a quick second move can't
146
150
  pull an edge backwards. Hover and active have separate duration/delay variables.
147
151
 
148
- `--tabs-hover-indicator-stretch` (default `1`) multiplies the hover pair's two edge delays in CSS,
149
- so `0` turns the hover stretch into a plain slide (both edges move together over the same
150
- duration). The active indicator and underline always stretch.
152
+ `--tabs-indicator-stretch` (default `1`) multiplies every indicator's two edge delays in CSS, so `0`
153
+ turns the stretch into a plain slide (both edges move together over the same duration) for the
154
+ hover, active and underline indicators alike. Renamed 2026-10-19 from `--tabs-hover-indicator-stretch`,
155
+ which covered the hover pair only.
151
156
 
152
157
  This replaced a `translate` + `scale` model (a bar-wide box translated to the tab's left and
153
158
  scaled to its width, plus a timeout to settle after a stretch, and one shared
@@ -0,0 +1,60 @@
1
+ # useSlidingIndicator
2
+
3
+ ## Overview
4
+
5
+ Positions one or more sliding indicators (a highlight or underline that moves between items) inside
6
+ a container, with a stretch-then-catch-up motion: the edge in the direction of travel moves first
7
+ and the trailing edge follows one duration later. Used by `useTabs` (TabbedContent) and
8
+ `SlidingToggle`. Lives in `app/composables/useSlidingIndicator.ts`, auto-imported.
9
+
10
+ ## API
11
+
12
+ ```ts
13
+ const { place, reset } = useSlidingIndicator(containerRef, { axis?: "x" | "y", duration });
14
+ ```
15
+
16
+ | Param | Type | Notes |
17
+ |---|---|---|
18
+ | `containerRef` | `Ref<HTMLElement \| null>` | The positioned element the indicators sit in. The CSS variables are written on it. |
19
+ | `axis` | `MaybeRefOrGetter<"x" \| "y">` | Default `"x"`. |
20
+ | `duration` | `MaybeRefOrGetter<number>` | Slide length per edge, in ms. Invalid or negative values count as `0`. |
21
+
22
+ `place(name, target, animate)` measures `target` (its `offsetLeft`/`offsetTop` and size against the
23
+ container's client size) and writes, on the container:
24
+
25
+ - `--_{name}-start` / `--_{name}-end` — insets from the container's start and end edges, in px;
26
+ - `--_{name}-duration`, `--_{name}-start-delay`, `--_{name}-end-delay`.
27
+
28
+ It returns the transition length in ms, or `null` when the container or target is missing (nothing
29
+ written). The first placement of a name, `animate: false`, and `prefers-reduced-motion` all snap
30
+ (duration `0ms`).
31
+
32
+ `reset(name)` forgets the last position, so the next placement snaps instead of sliding in from
33
+ wherever the indicator last was (use it when an indicator is hidden).
34
+
35
+ ## CSS side
36
+
37
+ ```css
38
+ .my-indicator {
39
+ position: absolute;
40
+ left: var(--_my-thing-active-start, 0px);
41
+ right: var(--_my-thing-active-end, 100%);
42
+ transition:
43
+ left var(--_my-thing-active-duration, 0ms) ease var(--_my-thing-active-start-delay, 0ms),
44
+ right var(--_my-thing-active-duration, 0ms) ease var(--_my-thing-active-end-delay, 0ms);
45
+ }
46
+ ```
47
+
48
+ Use physical `left`/`right` (and `top`/`bottom` for `"y"`): the measurements are physical, so this
49
+ also works in right-to-left layouts.
50
+
51
+ ## Rules
52
+
53
+ - Prefix the `name` with the component (`"sliding-toggle-active"`), so a component nested inside
54
+ another that uses the composable can't inherit its values (pitfall #26). `useTabs` keeps the
55
+ historic `"active"`/`"hovered"` names that TabbedContent's CSS reads.
56
+ - Re-place with `animate: false` when the container or any measured item resizes, and when items
57
+ are added or removed (`measured-layout-observers.md`). Don't observe `style` changes: the
58
+ composable writes inline styles on the container.
59
+ - Keep the indicators hidden until the first placement (SlidingToggle's `data-ready`), and give the
60
+ selected item its own fallback background for SSR and first paint.
@@ -75,6 +75,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
75
75
  ├── composable-cookie-consent.md — useCookieConsent: unset/granted/denied state, cookie persistence, wraps @nuxt/scripts' useScriptTriggerConsent
76
76
  ├── composable-analytics.md — useAnalytics: provider-agnostic trackEvent/page-view tracking (google-analytics only implemented), consent-gated, single call site for setup + firing events
77
77
  ├── composable-anchored-popover.md — useAnchoredPopover: popover + anchor positioning with JS fallbacks for Safari 16 (no Popover API) and 17–18 (no anchor positioning); used by ActionMenu/SelectMenu/DeepExpandingMenu/DisplayTooltip/PopOver
78
+ ├── composable-sliding-indicator.md — useSlidingIndicator: stretch-then-catch-up sliding indicator edges written as --_{name}-* vars, reduced-motion snap; used by useTabs/TabbedContent and SlidingToggle
78
79
  └── components/
79
80
  ├── alert-content.md — AlertContent + AlertContentInner: themed alert panel (icon/title/content/actions/dismiss) under DisplayToast, DisplayPrompt and AlertMaskedContent; showIcon, #actions row, --alert-content-* tokens, app.config icon map
80
81
  ├── alert-mask.md — AlertMask (renamed from AlertMaskCore): SVG border/fill sized to slotted content via ResizeObserver; config-prop geometry, --alert-mask-* colour tokens
@@ -164,6 +165,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
164
165
  ├── input-radio.md — MultipleRadiobuttons: fieldset radiogroup (data-driven, button or labelled), shared-name grouping bug fixed, dead label/placeholder/equalCols props removed, data-options-layout hook, spacing token API, useId button ids, wraps hostile content
165
166
  ├── toggle-switch.md — ToggleSwitch (bare control), ToggleSwitchField (labelled, layout="stacked"|"inline", replaces ToggleSwitchWithLabelInline): renamed from Core/WithLabel, role="switch", prefixed inner classes + data-checked, single-toggle click handling, reduced motion, round prop + border-radius token, track width from geometry, CSS token API, stress stories
166
167
  ├── segmented-control.md — SegmentedControl (renamed from TripleToggleSwitch): icon radio pill for any number of options with sliding marker, radiogroup + ariaLabel, system/light/dark marker-gradient tokens, reduced-motion, CSS token API
168
+ ├── sliding-toggle.md — SlidingToggle: single-choice segmented radiogroup with text labels of any width, measured sliding + hover indicators (useSlidingIndicator), roving tabindex/arrow keys, corner-shape token, CSS token API
167
169
  ├── entry-animation.md — EntryAnimation: scroll-driven entry animation wrapper (slide-in/zoom-reveal/exit-blur utility classes), skipAnimation for above-the-fold loop items, reduced-motion handled at the CSS layer
168
170
  ├── column-flow-grid.md — ColumnFlowGrid (renamed from MasonryGrid): CSS multi-column text-flow layout, named dynamic slots (no count/data prop), itemMinWidth/gap/unit sizing, CSS token API; not a true masonry, see "which one do I want?"
169
171
  ├── masonry-grid.md — MasonryGrid: real measured-height masonry (greedy shortest-column packing, animated resize) absorbed from the now-retired MasonryGridOrdered; named dynamic slots, fixedWidth/justify, CSS token API
@@ -172,6 +172,23 @@ const Template: StoryFn<StoryArgs> = (args) => ({
172
172
 
173
173
  Don't name the inline component the same as an exported story (`TS2451: Cannot redeclare`).
174
174
 
175
+ ### Generic components (`<script setup generic="T">`)
176
+
177
+ A generic component's type doesn't fit `Meta`'s `component` field (`ConcreteComponent<Args>`), so
178
+ `npx nuxi typecheck` fails on `component: MyGeneric`. Cast it, with a one-line comment; the
179
+ autodocs props table still generates:
180
+
181
+ ```ts
182
+ import type { ConcreteComponent } from "vue";
183
+
184
+ const meta: Meta<StoryArgs> = {
185
+ // Generic components don't fit Meta's ConcreteComponent<Args>; the props table still generates.
186
+ component: SlidingToggle as unknown as ConcreteComponent<StoryArgs>,
187
+ };
188
+ ```
189
+
190
+ `SlidingToggle.stories.ts` is the reference.
191
+
175
192
  ## Title format
176
193
 
177
194
  `"Category/Subcategory/ComponentName"` — this becomes the Storybook sidebar path and the
@@ -180,9 +197,12 @@ Playwright `STORY_BASE` slug. The slug is derived by lowercasing and replacing s
180
197
 
181
198
  | Title | STORY_BASE slug |
182
199
  | --------------------------------------------- | ------------------------------------------- |
183
- | `"Atoms/Text Blocks/HeroText"` | `atoms-text-blocks-herotext` |
200
+ | `"Atoms/Text Blocks/Hero Text"` | `atoms-text-blocks-hero-text` |
184
201
  | `"Components/Forms/Input Text/InputText"` | `components-forms-input-text-inputtext` |
185
202
 
203
+ Renaming a title changes the slug: update any `playwright/*.playwright.ts` `STORY_BASE` and any
204
+ story links (`?path=/story/...`) that point at it.
205
+
186
206
  ## argTypes control types
187
207
 
188
208
  | Prop type | `control` |
@@ -274,7 +294,7 @@ export const Default: Story = {
274
294
  Key points:
275
295
 
276
296
  - `computed` is **not** auto-imported in `.ts` story files — import it explicitly from `"vue"`.
277
- - Components aren't auto-registered in a story's `template` string either. Every component used there, including ones you put in the component's slots (`EyebrowText`, `HeroText`, `SocialIconsList`, ...), must be imported and listed in `components: {}`. Otherwise Vue renders it as an empty unknown element (`<eyebrowtext>`) with no error. ProfileSection's header and social-icons stories rendered blank this way until 2026-10-14.
297
+ - Components aren't auto-registered in a story's `template` string either. Every component used there, including ones you put in the component's slots (`EyebrowText`, `HeroText`, `SocialIconsList`, ...), must be imported and listed in `components: {}`. Otherwise Vue renders it as an empty unknown element (`<eyebrowtext>`) with no error. ProfileSection's header and social-icons stories rendered blank this way until 2026-10-14. Globally registered components are the exception and need no entry: `Icon` (`@nuxt/icon` registers it with `global: true`) and `NuxtLink`.
278
298
  - Props in a story's `template` string aren't type-checked either. An invalid value renders silently with no warning: ProfileSection's stories passed `font-size="large"` to `HeroText`, which only has `display`/`title`/`heading`/`subheading`/`label`, so the heading fell back to unstyled text. Check every literal prop value against the component's `interface Props`.
279
299
  - Extra args must be stripped before `v-bind` — spreading unknown keys onto a component makes them unknown HTML attributes.
280
300
  - CSS custom properties set via `:style` on the component root are picked up by `var()` in the component's scoped CSS.
@@ -99,13 +99,18 @@ test.describe("ComponentName — combinations", () => {
99
99
 
100
100
  ## Deriving the STORY_BASE slug
101
101
 
102
- Take the story `title` from the `.stories.ts` file, lowercase everything, remove spaces,
103
- and replace `/` with `-`:
104
-
105
- | Story `title` | `STORY_BASE` |
106
- | ------------------------------------------------- | ----------------------------------------------- |
107
- | `"Atoms/Text Blocks/HeroText"` | `atoms-text-blocks-herotext` |
108
- | `"Components/Forms/Input Button/InputButton"` | `components-forms-input-button-inputbuttoncore` |
102
+ Take the story `title` from the `.stories.ts` file, lowercase everything, and replace both spaces
103
+ and `/` with `-`:
104
+
105
+ | Story `title` | `STORY_BASE` |
106
+ | --------------------------------------------- | ------------------------------------------- |
107
+ | `"Atoms/Text Blocks/Hero Text"` | `atoms-text-blocks-hero-text` |
108
+ | `"Components/Forms/Input Button/InputButton"` | `components-forms-input-button-inputbutton` |
109
+
110
+ `STORY_BASE` is hardcoded, and nothing (Vitest, ESLint, the Component Ledger) checks it. When you
111
+ change a story `title`, update `STORY_BASE` in the component's `playwright/` folder in the same
112
+ change. EyebrowText and HeroText pointed at ids from an older title (`...-eyebrowtext`,
113
+ `...-herotext`) until 2026-10-19, so their suites loaded a missing story.
109
114
 
110
115
  The full story URL pattern is:
111
116