srcdev-nuxt-components 9.6.13 → 9.6.14

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 (53) 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/storybook-add-story.md +5 -2
  10. package/.claude/skills/testing-add-playwright.md +12 -7
  11. package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
  12. package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
  13. package/.vscode/srcdev-component-link-text.code-snippets +3 -3
  14. package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
  15. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
  16. package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
  17. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
  18. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
  19. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
  20. package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
  21. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
  22. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
  23. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
  24. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
  25. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
  26. package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
  27. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
  28. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
  29. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
  30. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
  31. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
  32. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
  33. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
  34. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
  35. package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
  36. package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
  37. package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
  38. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
  39. package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
  40. package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
  41. package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
  42. package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
  43. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
  44. package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
  45. package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
  46. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
  47. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
  48. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
  49. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
  50. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
  51. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
  52. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
  53. 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.
@@ -180,9 +180,12 @@ Playwright `STORY_BASE` slug. The slug is derived by lowercasing and replacing s
180
180
 
181
181
  | Title | STORY_BASE slug |
182
182
  | --------------------------------------------- | ------------------------------------------- |
183
- | `"Atoms/Text Blocks/HeroText"` | `atoms-text-blocks-herotext` |
183
+ | `"Atoms/Text Blocks/Hero Text"` | `atoms-text-blocks-hero-text` |
184
184
  | `"Components/Forms/Input Text/InputText"` | `components-forms-input-text-inputtext` |
185
185
 
186
+ Renaming a title changes the slug: update any `playwright/*.playwright.ts` `STORY_BASE` and any
187
+ story links (`?path=/story/...`) that point at it.
188
+
186
189
  ## argTypes control types
187
190
 
188
191
  | Prop type | `control` |
@@ -274,7 +277,7 @@ export const Default: Story = {
274
277
  Key points:
275
278
 
276
279
  - `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.
280
+ - 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
281
  - 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
282
  - Extra args must be stripped before `v-bind` — spreading unknown keys onto a component makes them unknown HTML attributes.
280
283
  - 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
 
@@ -15,8 +15,19 @@
15
15
  " aria-label=\"$2\"",
16
16
  " black-color=\"${3:currentColor}\"",
17
17
  " :radius=\"${4:0.5}\"",
18
- " :variant=\"{ inner: '${5|default,circle,rounded,dots,classy,classy-rounded|}', marker: '${6|default,circle,rounded,dots,classy,classy-rounded|}', pixel: '${7|default,circle,rounded,dots,classy,classy-rounded|}' }\"",
18
+ " :variant=\"{ inner: '${5|default,circle,rounded,dots,pixelated|}', marker: '${6|default,circle,rounded,dots,pixelated|}', pixel: '${7|default,circle,rounded,dots,pixelated|}' }\"",
19
19
  "/>"
20
20
  ]
21
+ },
22
+ "SRCDEV DisplayQrCode Fallback": {
23
+ "description": "DisplayQrCode with a fallback for values too long to encode",
24
+ "scope": "vue,html",
25
+ "body": [
26
+ "<DisplayQrCode :qr-value=\"${1:url}\" aria-label=\"${2:QR code}\" @error=\"${3:onQrError}\">",
27
+ " <template #fallback>",
28
+ " $0",
29
+ " </template>",
30
+ "</DisplayQrCode>"
31
+ ]
21
32
  }
22
33
  }
@@ -4,11 +4,27 @@
4
4
  "scope": "vue,html",
5
5
  "body": [
6
6
  "<HeroText",
7
- " tag=\"$1h1\"",
8
- " fontSize=\"$2title\"",
9
- " :textContent=\"[",
10
- " { text: '$3Designing', styleClass: 'normal' },",
11
- " { text: '$4Artistry', styleClass: 'accent' },",
7
+ " tag=\"${1|h1,h2,h3,h4,h5,h6|}\"",
8
+ " font-size=\"${2|title,display,heading,subheading,label|}\"",
9
+ " :text-content=\"[",
10
+ " { text: '${3:Designing}', styleClass: 'normal' },",
11
+ " { text: '${4:Artistry}', styleClass: 'accent' },",
12
+ " ]\"",
13
+ "/>"
14
+ ]
15
+ },
16
+ "SRCDEV HeroText Vertical With Icon": {
17
+ "description": "HeroText stacked vertically with a decorative icon",
18
+ "scope": "vue,html",
19
+ "body": [
20
+ "<HeroText",
21
+ " tag=\"${1|h2,h1,h3,h4,h5,h6|}\"",
22
+ " axis=\"vertical\"",
23
+ " icon=\"${2:lucide:sparkles}\"",
24
+ " font-size=\"${3|display,title,heading,subheading,label|}\"",
25
+ " :text-content=\"[",
26
+ " { text: '${4:Modern}', styleClass: 'normal' },",
27
+ " { text: '${5:Living}', styleClass: 'accent' },",
12
28
  " ]\"",
13
29
  "/>"
14
30
  ]
@@ -19,11 +19,11 @@
19
19
  "description": "CSS override scaffold for LinkText tokens",
20
20
  "scope": "css",
21
21
  "body": [
22
- ".$1my-page {",
22
+ ".${1:my-page} {",
23
23
  " --link-text-colour: $2;",
24
24
  " --link-text-colour-hover: $3;",
25
- " --link-text-decoration: $4underline;",
26
- " --link-text-decoration-hover: $5none;",
25
+ " --link-text-decoration: ${4:underline};",
26
+ " --link-text-decoration-hover: ${5:none};",
27
27
  "}"
28
28
  ]
29
29
  }
@@ -40,5 +40,33 @@
40
40
  " </template>",
41
41
  "</PopOver>"
42
42
  ]
43
+ },
44
+ "SRCDEV PopOver Localised Close": {
45
+ "description": "PopOver with a localised close label and a custom close icon",
46
+ "scope": "vue,html",
47
+ "body": [
48
+ "<PopOver close-button-aria-label=\"${1:Close}\" close-icon=\"${2:lucide:x}\">",
49
+ " <template #trigger>",
50
+ " <span>$3</span>",
51
+ " </template>",
52
+ " <template #content>",
53
+ " <p>$0</p>",
54
+ " </template>",
55
+ "</PopOver>"
56
+ ]
57
+ },
58
+ "SRCDEV PopOver CSS Overrides": {
59
+ "description": "Unlayered CSS override scaffold for PopOver public tokens",
60
+ "scope": "css,vue",
61
+ "body": [
62
+ ".${1:my-section} {",
63
+ " --pop-over-width: ${2:30rem};",
64
+ " --pop-over-gap: ${3:1rem};",
65
+ " --pop-over-background-colour: ${4:var(--slate-00)};",
66
+ " --pop-over-text-colour: ${5:var(--slate-09)};",
67
+ " --pop-over-border-colour: ${6:var(--slate-02)};",
68
+ " --pop-over-content-padding: ${7:1.6rem};$0",
69
+ "}"
70
+ ]
43
71
  }
44
72
  }
@@ -7,7 +7,8 @@
7
7
  | `--pop-over-gap` | `1rem` | Space between the trigger and the popover panel |
8
8
  | `--pop-over-trigger-outline-width` | `0.1rem` | Trigger focus outline width |
9
9
  | `--pop-over-trigger-outline-colour-hover` | `var(--theme-ring)` | Trigger outline colour on focus-visible |
10
- | `--pop-over-width` | `30rem` | Popover panel width |
10
+ | `--pop-over-width` | `30rem` | Popover panel width, capped at the viewport width minus `3.2rem` |
11
+ | `--pop-over-max-block-size` | `calc(100dvh - 3.2rem)` | Popover panel maximum height; taller content scrolls inside the panel |
11
12
  | `--pop-over-border-width` | `0.1rem` | Popover panel border width |
12
13
  | `--pop-over-border-colour` | `var(--slate-02)` | Popover panel border colour |
13
14
  | `--pop-over-border-radius` | `0.8rem` | Popover panel corner radius |
@@ -18,7 +19,7 @@
18
19
  | `--pop-over-z-index` | `999999` | Panel stacking order in browsers without CSS anchor positioning. Ignored where the panel renders in the top layer |
19
20
  | `--pop-over-content-padding` | `1.6rem` | Padding around the `content` slot |
20
21
  | `--pop-over-content-padding-block-start` | `3.2rem` | Extra top padding so content clears the close button |
21
- | `--pop-over-close-button-offset` | `0.8rem` | Distance of the close button from the panel's top/right edges |
22
+ | `--pop-over-close-button-offset` | `0.8rem` | Distance of the close button from the panel's block-start and inline-end edges (top right in left-to-right text, top left in right-to-left) |
22
23
  | `--pop-over-close-button-size` | `2.4rem` | Close button box width/height |
23
24
  | `--pop-over-close-button-border-radius` | `50%` | Close button corner radius |
24
25
  | `--pop-over-close-button-colour` | `var(--slate-06)` | Close button icon colour |
@@ -27,6 +28,41 @@
27
28
  | `--pop-over-close-button-surface-hover` | `var(--slate-01)` | Close button background on hover/focus-visible |
28
29
  | `--pop-over-close-button-outline-colour-hover` | `var(--theme-ring)` | Close button outline colour on hover/focus-visible |
29
30
 
31
+ Private tokens (not public API): `--_anchor-name` (the generated CSS anchor name), and `--_anchor-top`, `--_anchor-right`, `--_anchor-bottom`, `--_anchor-left`, and their `-inverse` forms (measured trigger edges, set by `useAnchoredPopover` only where CSS anchor positioning is unsupported).
32
+
33
+ ---
34
+
35
+ ## State hooks
36
+
37
+ | Hook | Where | Meaning |
38
+ |---|---|---|
39
+ | `[data-placement]` | `.pop-over` root | The `placement` prop: `top`, `right`, `bottom` or `left` |
40
+ | `[data-placement]` | `.pop-over-popover` | The resolved side, which can be the opposite of the prop when there is no room (only set where CSS anchor positioning is unsupported) |
41
+ | `:popover-open` | `.pop-over-popover` | Panel open, where the Popover API exists |
42
+ | `.pop-over-popover-open` | `.pop-over-popover` | Panel open on Safari 16 (no Popover API). Style it in a separate rule from `:popover-open` |
43
+ | `[role="dialog"]` | `.pop-over-popover` | Present only when `popoverAriaLabel` is set |
44
+
45
+ Inner class names: `.pop-over-trigger` (the trigger `<button>`), `.pop-over-popover` (the panel),
46
+ `.pop-over-close-button` (an `InputButton variant="inline"`, so also `.input-button`),
47
+ `.pop-over-close-button-icon`, `.pop-over-content` (wraps the `content` slot).
48
+
49
+ > Changed 2026-10-19: the close button is now an `InputButton` (`variant="inline"`, icon-only, with
50
+ > the `closeButtonAriaLabel` text visually hidden) instead of a raw `<button>`, and sits at the
51
+ > inline end, so it moves to the left in right-to-left text. The panel now caps at the viewport
52
+ > width and height and wraps unbroken strings.
53
+
54
+ ---
55
+
56
+ ## Local overrides
57
+
58
+ Set the tokens above on an element you own (a page or section class, or a class added with
59
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
60
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
61
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
62
+ `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
63
+
64
+ ### Page or section
65
+
30
66
  ```css
31
67
  .my-page {
32
68
  --pop-over-background-colour: #1a1a1a;
@@ -35,7 +71,7 @@
35
71
  }
36
72
  ```
37
73
 
38
- Or scope to a single instance via `styleClassPassthrough`:
74
+ ### One instance
39
75
 
40
76
  ```vue
41
77
  <PopOver style-class-passthrough="promo-pop-over">...</PopOver>
@@ -43,13 +79,10 @@ Or scope to a single instance via `styleClassPassthrough`:
43
79
 
44
80
  ---
45
81
 
46
- ## Local overrides
82
+ ## Class passthrough
47
83
 
48
- Set the tokens above on an element you own (a page or section class, or a class added with
49
- `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
50
- `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
51
- `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
52
- `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
84
+ `style-class-passthrough` adds classes to the `.pop-over` root, which wraps both the trigger and the
85
+ panel, so tokens set on that class reach the panel too.
53
86
 
54
87
  ---
55
88