srcdev-nuxt-components 9.6.5 → 9.6.7

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 (35) hide show
  1. package/.claude/rules/consumer/a11y-no-focus-steal.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +5 -1
  3. package/.claude/skills/components/display-toast.md +6 -3
  4. package/.claude/skills/components/page-hero-highlights.md +22 -18
  5. package/.claude/skills/components/social-icons-list.md +32 -5
  6. package/.claude/skills/css-dynamic-border-radius.md +114 -0
  7. package/.claude/skills/index.md +3 -2
  8. package/.claude/skills/storybook-add-story.md +6 -0
  9. package/.vscode/srcdev-component-display-toast.code-snippets +6 -8
  10. package/.vscode/srcdev-component-page-hero-highlights.code-snippets +2 -1
  11. package/.vscode/srcdev-component-social-icons-list.code-snippets +14 -0
  12. package/.vscode/srcdev-utility-dynamic-border-radius.code-snippets +31 -0
  13. package/app/assets/styles/setup/06.utility-classes/_dynamic-border-radius.css +27 -0
  14. package/app/assets/styles/setup/06.utility-classes/index.css +1 -0
  15. package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +97 -8
  16. package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +80 -14
  17. package/app/components/02.molecules/social-icons-list/stories/SocialIconsList.stories.ts +230 -2
  18. package/app/components/02.molecules/social-icons-list/stories/social-icons-list-recipes.css +108 -0
  19. package/app/components/02.molecules/social-icons-list/tests/SocialIconsList.spec.ts +123 -52
  20. package/app/components/02.molecules/social-icons-list/tests/__snapshots__/SocialIconsList.spec.ts.snap +10 -4
  21. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +9 -3
  22. package/app/components/02.molecules/toast/DisplayToast.vue +10 -10
  23. package/app/components/02.molecules/toast/DisplayToastProvider.vue +12 -4
  24. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +200 -2
  25. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +33 -12
  26. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +20 -0
  27. package/app/components/04.templates/page-hero-highlights/CONSUMER-STYLING.md +32 -12
  28. package/app/components/04.templates/page-hero-highlights/PageHeroHighlights.vue +56 -47
  29. package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlights.stories.ts +158 -307
  30. package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlightsHeader.stories.ts +10 -0
  31. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +49 -27
  32. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlightsHeader.spec.ts +42 -7
  33. package/app/components/04.templates/page-hero-highlights/tests/__snapshots__/PageHeroHighlights.spec.ts.snap +12 -10
  34. package/app/types/components/social-icons-list.d.ts +9 -0
  35. package/package.json +1 -1
@@ -0,0 +1,14 @@
1
+ ---
2
+ paths:
3
+ - "app/**/*.vue"
4
+ ---
5
+
6
+ # Status messages never take focus
7
+
8
+ Never `.focus()` a toast, banner or other status message when it appears. Announce it with `role`
9
+ + `aria-live`, keep it reachable with `tabindex="0"`, and listen for Escape on the document while
10
+ it's shown (skipping `event.defaultPrevented`).
11
+
12
+ Why: WCAG 4.1.3 says status messages are announced without receiving focus, and moving focus
13
+ interrupts typing. Chrome also counts script focus before any pointer input as `:focus-visible`,
14
+ so `:focus:not(:focus-visible)` can't hide the ring (`DisplayToast`, 2026-10-16).
@@ -178,7 +178,9 @@ The ledger can't see items 6c, 9, 10, 11 and 14, so check those by hand.
178
178
  stories/tests up one level, fix relative imports); `pathPrefix: false` keeps the auto-import
179
179
  names unchanged. All existing `variants/` folders were flattened 2026-09-25.
180
180
  8. **VS Code snippet** — create or update `.vscode/srcdev-component-{family}.code-snippets`,
181
- covering the control and its Field wrapper in one file.
181
+ covering the control and its Field wrapper in one file. For a teleported component, a CSS
182
+ override scaffold must not nest its selector under a page class: it never matches (the
183
+ `DisplayToast` snippet did, fixed 2026-10-16).
182
184
  9. **Accessibility** — check for gaps beyond what already-passing tests would catch:
183
185
  - Interactive controls (buttons, toggles, custom form-like widgets) have an accessible name —
184
186
  visible text, `aria-label`, or `aria-labelledby`.
@@ -188,6 +190,8 @@ The ledger can't see items 6c, 9, 10, 11 and 14, so check those by hand.
188
190
  `event.preventDefault()` with a comment like "could add this later", and no screen-reader
189
191
  copy (visible or `sr-only`) that describes an interaction the component doesn't actually
190
192
  perform. Cross-check every claim in the copy against the handler that's supposed to back it.
193
+ - A toast, banner or status message doesn't `.focus()` itself when it appears (rule
194
+ `consumer/a11y-no-focus-steal.md`).
191
195
  - Focus is visible (`:focus-visible`, not a suppressed outline) on anything focusable.
192
196
  - Live-updating or auto-advancing content (carousels, marquees, auto-dismissing toasts) has a
193
197
  way to pause it, per WCAG 2.2.2 — a visible control, or pause-on-hover/focus at minimum.
@@ -111,6 +111,7 @@ const toastVisible = ref(false)
111
111
 
112
112
  - `onBeforeRouteLeave` dismisses the toast on navigation. This emits a Vue Router warning in Vitest (no active route record) — harmless.
113
113
  - `returnFocusTo` accepts an `HTMLElement` or a component instance with `$el`.
114
+ - Neither `DisplayToast` nor `DisplayToastProvider` moves focus when a toast appears (changed 2026-10-16; both used to `.focus()` the toast). Toasts are status messages announced through `role` + `aria-live` (WCAG 4.1.3), and stealing focus interrupted typing and drew a focus ring in Chrome when the toast opened before any pointer input. Keyboard users can still Tab to it (`tabindex="0"`), and Escape is heard on the document while a toast is shown (ignored if another handler called `preventDefault()`).
114
115
  - The toast uses `<Teleport to="body">`. In tests, query `document.querySelector(".display-toast")` not `wrapper.find()`.
115
116
  - Position, alignment and state are `data-position`, `data-alignment` (`full-width` when `fullWidth`) and `data-state="show|hide"`, not classes (changed 2026-10-08: the old bare `.top`/`.hide`/... classes could collide with a consumer's utility classes, since the toast is teleported into `<body>`).
116
117
  - In tests, unmount every toast in `afterEach` before clearing `document.body`: a leftover instance's pending dismiss timer otherwise re-renders into the removed container (`insertBefore` of null) during a later test's timer advance.
@@ -119,8 +120,8 @@ const toastVisible = ref(false)
119
120
 
120
121
  Auto-dismiss pauses while hovered, or while keyboard focus is on or inside the toast, and resumes with
121
122
  the remaining time (`createPausableTimeout` in `app/utils/pausableTimeout.ts`, plus
122
- `focusPausesAutoDismiss`). The toast's own programmatic focus only pauses it when the root matches
123
- `:focus-visible` (keyboard modality), so mouse users still get auto-dismiss. `data-paused` freezes the
123
+ `focusPausesAutoDismiss`). Focus on the toast root only pauses it when the root matches
124
+ `:focus-visible` (keyboard modality), so a mouse click on it doesn't stop auto-dismiss. `data-paused` freezes the
124
125
  progress bar via `animation-play-state`. In jsdom `:focus-visible` matches any focus, so tests that
125
126
  depend on modality spy on `Element.prototype.matches` (see `DisplayToast.spec.ts`).
126
127
 
@@ -244,6 +245,8 @@ The SVG border shape matches the standard toast exactly (8px left radius, 4px ri
244
245
 
245
246
  The inner content layout is handled by the shared `AlertContentInner` molecule — both variants reuse it. `AlertMaskedContent` sets `--alert-content-inner-background: transparent` so the glass effect shows through.
246
247
 
248
+ **Contrast caveat:** a toast is fixed to the viewport, so whatever scrolls behind it (body copy, busy photos) shows through the fill. 80% holds up over most backdrops; lowering `--alert-masked-content-background` can make the message hard to read, and the 1px accent border can disappear over a backdrop of similar hue. Set that token on `.display-toast` (or a `styleClassPassthrough` class) or `.display-toast-provider-item`: both teleport to `<body>`, so a page-level ancestor never reaches them. Prefer the opaque variant for error/warning toasts. Storybook's "Masked: Low Contrast Risk" story demos the failure.
249
+
247
250
  ### Provider notes
248
251
 
249
252
  - `useToastQueue` state persists across route changes — no `onBeforeRouteLeave` cleanup needed.
@@ -252,4 +255,4 @@ The inner content layout is handled by the shared `AlertContentInner` molecule
252
255
  - Every mounted provider reacts to the shared `useToastQueue` state, so tests must unmount providers in `afterEach`; otherwise an earlier test's provider runs its own timer for a new toast and dismisses it.
253
256
  - Each item pauses independently (hover / keyboard focus, same rule as the standalone toast), tracked per toast id.
254
257
  - The progress bar (`.display-toast-provider-progress`) is only rendered when `autoDismiss: true`.
255
- - Dismissal can be triggered by: close button click, Escape key (when the toast item has focus), `dismiss(id)`, auto-dismiss timer, or `clear()`.
258
+ - Dismissal can be triggered by: close button click, Escape key (dismisses that item when focus is inside it, otherwise the newest visible item), `dismiss(id)`, auto-dismiss timer, or `clear()`.
@@ -1,5 +1,7 @@
1
1
  # PageHeroHighlights Component
2
2
 
3
+ > **Changed 2026-10-16:** `widthConstrained` was inverted in the CSS since it was first added (`true` gave full width, `false` capped the column); it now does what this doc always said. The tablet/desktop gutters never applied (their `@container` queries had no container): the root is now a named `page-hero-highlights` inline-size container and the rows sit in a `.page-hero-highlights-grid` wrapper. Colour defaults are light theme tokens (were `darkblue`/`white`/`black`/`--slate-*`). Equal-width highlights wrap below `--page-hero-highlights-item-min-width` (12rem). Grid tracks are `minmax(0, …)` and items get `overflow-wrap: anywhere`.
4
+
3
5
  > **Changed 2026-09-28:** styles are now in `@layer components` (they were unlayered). Every token is `--page-hero-highlights-*` with its default as a `var()` fallback, so it can be set on the component or any ancestor (previously declared on the root with generic names like `--max-width`, `--highlight-border`, so ancestor values never landed). Inner classes are prefixed (`.page-hero-highlights-header-row`, `-header-slot`, `-strip`, `-content-row`, `-content-slot`); the content-align root classes are `.content-align-start`/`.content-align-center` (were bare `.start`/`.center`); the highlight contract classes are `.page-hero-highlights-item`, `-item-title`, `-item-body` (were `.highlight`, `.title`, `.body`); PageHeroHighlightsHeader uses `.page-hero-highlights-header-start`/`-end` and `--page-hero-highlights-header-*` tokens (were `.phh-*` / `--phh-*`). Full token reference: `CONSUMER-STYLING.md` in the component folder.
4
6
 
5
7
  ## Overview
@@ -19,7 +21,7 @@ The layout uses a 4-row CSS Grid with `subgrid` — no `translate`, negative mar
19
21
  | `tag` | `"div" \| "section" \| "main"` | `"div"` | Root element tag |
20
22
  | `highlightsEqualWidths` | `boolean` | `false` | Equal-width grid columns for highlight items |
21
23
  | `highlightsJustify` | `"start" \| "center" \| "end" \| "space-between" \| "space-around"` | `"start"` | Alignment of highlight items along the main axis |
22
- | `widthConstrained` | `boolean` | `false` | When `true`, caps the central column at `--width-constrained` (default `1064px`). Gutters grow responsively to enforce the constraint. |
24
+ | `widthConstrained` | `boolean` | `false` | When `true`, caps the central column at `--page-hero-highlights-max-width` (default `1064px`). Gutters grow responsively to enforce the constraint. |
23
25
  | `contentAlign` | `"start" \| "center"` | `"center"` | When `widthConstrained` is `true`: `"center"` grows gutters equally; `"start"` pins content to the left with a fixed left gutter. |
24
26
  | `contentPanel` | `boolean` | `true` | When `true`, renders a decorative panel behind the content slot and offsets the highlights strip. Set to `false` for a flat layout with no backdrop. |
25
27
  | `highlightTitleBaseline`| `boolean` | `false` | When `true`, fixes the highlight title row to a set height so titles align at a common baseline. Override `--page-hero-highlights-title-height` to tune. |
@@ -166,7 +168,7 @@ See [component-aria-landmark.md](../component-aria-landmark.md) for the full pat
166
168
 
167
169
  ## Equal-width highlights
168
170
 
169
- By default, highlight items size to their content (`flex-wrap`). Pass `:highlights-equal-widths="true"` to switch to a grid where all items share equal column widths:
171
+ By default, highlight items size to their content (`flex-wrap`). Pass `:highlights-equal-widths="true"` to switch to a grid where all items share equal column widths (`repeat(auto-fit, minmax(min(100%, --page-hero-highlights-item-min-width), 1fr))`), wrapping onto new rows when they'd get narrower than the min width (default `12rem`):
170
172
 
171
173
  ```vue
172
174
  <PageHeroHighlights :highlights-equal-widths="true">
@@ -176,10 +178,10 @@ By default, highlight items size to their content (`flex-wrap`). Pass `:highligh
176
178
 
177
179
  ## Constraining the central column width
178
180
 
179
- Pass `:width-constrained="true"` to cap the content column at `--width-constrained` (default `1064px`). The gutters grow responsively to enforce it — full-bleed backgrounds are unaffected and `subgrid` continues to work. Use `content-align` to pin to the left or centre:
181
+ Pass `:width-constrained="true"` to cap the content column at `--page-hero-highlights-max-width` (default `1064px`). The gutters grow responsively to enforce it — full-bleed backgrounds are unaffected and `subgrid` continues to work. Use `content-align` to pin to the left or centre:
180
182
 
181
183
  ```vue
182
- <!-- Centred, capped at --width-constrained (1064px) -->
184
+ <!-- Centred, capped at --page-hero-highlights-max-width (1064px) -->
183
185
  <PageHeroHighlights :width-constrained="true" content-align="center">...</PageHeroHighlights>
184
186
 
185
187
  <!-- Left-pinned (right side takes remaining space) -->
@@ -190,12 +192,12 @@ The maximum width value and gutter sizes are all CSS tokens — override them vi
190
192
 
191
193
  ```css
192
194
  .my-page-hero {
193
- --width-constrained: 1200px;
195
+ --page-hero-highlights-max-width: 1200px;
194
196
  --page-hero-highlights-gutter-desktop: 48px;
195
197
  }
196
198
  ```
197
199
 
198
- See [css-grid-width-constrained-gutters.md](../css-grid-width-constrained-gutters.md) for the underlying pattern explanation.
200
+ See [css-grid-max-width-gutters.md](../css-grid-max-width-gutters.md) for the underlying pattern explanation.
199
201
 
200
202
  ## Local style override scaffold
201
203
 
@@ -215,40 +217,41 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
215
217
  .page-hero-highlights {
216
218
  &.my-page-hero {
217
219
  /* Grid layout */
218
- /* --width-constrained: 1064px; */
220
+ /* --page-hero-highlights-max-width: 1064px; */
219
221
  /* --page-hero-highlights-gutter-mobile: 16px; */
220
222
  /* --page-hero-highlights-gutter-tablet: 40px; */
221
223
  /* --page-hero-highlights-gutter-desktop: 32px; */
222
224
 
223
225
  /* Header zone */
224
- /* --page-hero-highlights-header-background: darkblue; */
226
+ /* --page-hero-highlights-header-background: var(--theme-surface-subtle); */
225
227
 
226
228
  /* Highlights strip */
227
229
  /* --page-hero-highlights-strip-gap: 1rem; */
228
230
  /* --page-hero-highlights-strip-inset: 1.2rem; */
231
+ /* --page-hero-highlights-item-min-width: 12rem; */ /* equal-widths wrap point */
229
232
 
230
233
  /* Highlight cards */
231
234
  /* --page-hero-highlights-item-rows-gap: 1.2rem; */
232
235
  /* --page-hero-highlights-title-height: 1fr; */ /* see: highlight-title-baseline prop */
233
236
  /* --page-hero-highlights-item-padding-block-start: 1.2rem; */
234
237
  /* --page-hero-highlights-item-padding: 1.2rem; */
235
- /* --page-hero-highlights-item-background: white; */
236
- /* --page-hero-highlights-item-border: 1px solid black; */
238
+ /* --page-hero-highlights-item-background: var(--page-bg, Canvas); */
239
+ /* --page-hero-highlights-item-border: 1px solid var(--theme-border); */
237
240
  /* --page-hero-highlights-item-border-radius: 8px; */
238
- /* --page-hero-highlights-item-colour: black; */
241
+ /* --page-hero-highlights-item-colour: var(--theme-text); */
239
242
 
240
243
  /* Content zone */
241
- /* --page-hero-highlights-content-background: var(--slate-01); */ /* transparent */
244
+ /* --page-hero-highlights-content-background: transparent; */
242
245
  /* --page-hero-highlights-content-start-gap: 1.2rem; */
243
246
  /* --page-hero-highlights-content-end-gap: 1.2rem; */
244
247
 
245
248
  /* Content slot decorative border */
246
249
  /* --page-hero-highlights-content-margin-block-start: 2.4rem; */
247
250
  /* --page-hero-highlights-content-margin: var(--page-hero-highlights-strip-inset); */
248
- /* --page-hero-highlights-panel-background: var(--slate-00); */
249
- /* --page-hero-highlights-panel-border: 1px solid var(--slate-06); */
251
+ /* --page-hero-highlights-panel-background: var(--theme-surface-subtle); */
252
+ /* --page-hero-highlights-panel-border: 1px solid var(--theme-border); */
250
253
  /* --page-hero-highlights-panel-border-radius: 0.8rem; */
251
- /* --page-hero-highlights-panel-outline: 1px solid var(--slate-02); */
254
+ /* --page-hero-highlights-panel-outline: none; */
252
255
 
253
256
  /* When using :highlight-title-baseline="true" */
254
257
  /* --page-hero-highlights-title-height-baseline: 4rem; */ /* proportional value preferred */
@@ -268,10 +271,10 @@ row3: highlights bottom half
268
271
  row4: page content (never underflows highlights)
269
272
  ```
270
273
 
271
- `.page-hero-highlights-header-row` spans cols 1–3, rows 1–2 (edge-to-edge bg). `.page-hero-highlights-header-slot` is placed in row 1 only.
274
+ All of these sit in `.page-hero-highlights-grid`, the grid inside the root (the root is the size container). `.page-hero-highlights-header-row` spans cols 1–3, rows 1–2 (edge-to-edge bg). `.page-hero-highlights-header-slot` is placed in row 1 only.
272
275
  `.page-hero-highlights-content-row` spans cols 1–3, rows 3–4 (bg fills behind highlights; `.page-hero-highlights-content-slot` is placed in row 4 only). The decorative border behind `.page-hero-highlights-content-slot` is rendered via `.page-hero-highlights-content-row::before` — there is no separate DOM element for it.
273
276
 
274
- Grid columns are determined entirely by CSS — no `v-bind`. The `widthConstrained` and `contentAlign` props add CSS classes (`width-constrained`, `start`, `center`) which select the appropriate `grid-template-columns` rule.
277
+ Grid columns are determined entirely by CSS — no `v-bind`. The `widthConstrained` and `contentAlign` props add CSS classes (`width-constrained`, `content-align-start`, `content-align-center`) on the root, which select the `grid-template-columns` rule on `.page-hero-highlights-grid`.
275
278
 
276
279
  ## Layout pitfall: do not use `grid-template-rows: subgrid` inside `.page-hero-highlights-strip`
277
280
 
@@ -300,4 +303,5 @@ The `.page-hero-highlights-strip` element spans rows 2–3 of the parent grid (t
300
303
  - Lives in `app/components/04.templates/page-hero-highlights/`.
301
304
  - Storybook title: `"Templates/PageHeroHighlights"`.
302
305
  - **`contentAlign` has no effect when `widthConstrained` is `false`** — both gutters hold their responsive default.
303
- - **Gutter sizes are CSS tokens** — `--page-hero-highlights-gutter-mobile/tablet/desktop` are all overridable. The responsive switching between them (via `@container`) is handled internally and cannot be overridden.
306
+ - **Gutter sizes are CSS tokens** — `--page-hero-highlights-gutter-mobile/tablet/desktop` are all overridable. They switch at 768px and 1024px of the component's own width (`@container page-hero-highlights`); the breakpoints themselves aren't tokens.
307
+ - **The root is an inline-size container**, so it can't size from its content: as a flex or grid item give it `inline-size: 100%` or a track. A grid on the root itself was moved to `.page-hero-highlights-grid` for this (an element can't query its own size).
@@ -13,6 +13,7 @@
13
13
  | `items` | `ISocialIcon[]` | — | **Required.** Array of social network items. |
14
14
  | `label` | `string` | `"Social media profiles"` | `aria-label` applied to the `<ul>` element. |
15
15
  | `linkLabelTemplate` | `string` | `"{network} profile (opens in a new tab)"` | `aria-label` for each link; `{network}` is replaced with the item's `networkName`. |
16
+ | `showLabel` | `boolean` | `false` | Show each item's `networkName` as visible text after the icon. Filling the `label` slot shows it too. |
16
17
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `<ul>`. |
17
18
 
18
19
  ---
@@ -26,11 +27,25 @@ interface ISocialIcon {
26
27
  iconName: string; // Iconify icon name, e.g. "logos:instagram-icon"
27
28
  baseHref: string; // Base URL including trailing slash, e.g. "https://www.instagram.com/"
28
29
  profileId: string; // Profile identifier appended to baseHref
30
+ brandColour?: string; // Exposed as --social-icons-list-brand-colour on the item, for recipes
29
31
  }
30
32
  ```
31
33
 
32
34
  The rendered `href` is `${baseHref}${profileId}`.
33
35
 
36
+ ---
37
+
38
+ ## Slots
39
+
40
+ Both are scoped and receive `{ item }` (the `ISocialIcon`). Types: `SocialIconsListSlots`.
41
+
42
+ | Slot | Replaces | Notes |
43
+ |------|----------|-------|
44
+ | `icon` | The `<Icon>` inside `.social-icons-list-icon-wrapper` | The wrapper is `aria-hidden` and keeps the size and circle tokens, so slot content (an initial, `DisplayAvatar`, an image) is decorative and sized by `--social-icons-list-icon-size`. |
45
+ | `label` | The visible `networkName` text | Filling it shows the label without `showLabel` (e.g. name plus `@handle`). |
46
+
47
+ Slot content sits inside the link: keep it non-interactive (no buttons or nested links).
48
+
34
49
  ### Importing the type
35
50
 
36
51
  **Within the layer** (e.g. in another layer component or composable):
@@ -57,6 +72,8 @@ import type { ISocialIcon } from "srcdev-nuxt-components";
57
72
  | YouTube | `logos:youtube-icon` | `https://www.youtube.com/@` |
58
73
  | TikTok | `logos:tiktok-icon` | `https://www.tiktok.com/@` |
59
74
 
75
+ Mono equivalents for recipes, with brand colours: `bi:facebook` (`#1877f2`), `bi:twitter-x` (`#000000`), `bi:instagram` (`#e1306c`), `bi:youtube` (`#ff0000`), `bi:tiktok` (`#000000`).
76
+
60
77
  `logos:instagram-icon`, `logos:x` and other dark brand icons vanish on a dark background, and their colours can't be changed. On a dark background use a mono icon such as `mdi:instagram` (any set that draws in `currentColor`) and colour it with `--social-icons-list-icon-colour` (default `currentColor`). Installed mono Instagram icons: `mdi:instagram`, `bi:instagram`, `lucide:instagram`, `akar-icons:instagram-fill`, `radix-icons:instagram-logo`.
61
78
 
62
79
  ---
@@ -88,14 +105,21 @@ import type { ISocialIcon } from "srcdev-nuxt-components";
88
105
 
89
106
  | Property | Default | Notes |
90
107
  |----------|---------|-------|
91
- | `--social-icons-list-icon-size` | `2.4rem` | Icon size (applied as `font-size`) |
92
- | `--social-icons-list-gap` | `1.2rem` | Gap between icons in the flex row |
108
+ | `--social-icons-list-icon-size` | `2.4rem` | Icon size (a `font-size` on the icon wrapper) |
109
+ | `--social-icons-list-gap` | `1.2rem` | Gap between items |
110
+ | `--social-icons-list-icon-colour` / `-hover` | `currentColor` | Mono icons only; `logos:` colours are fixed |
111
+ | `--social-icons-list-link-padding` / `-border` / `-radius` / `-background` / `-background-hover` / `-shadow` / `-shadow-hover` / `-gap` | none / `0.8rem` gap | Turn links into tiles or pills |
112
+ | `--social-icons-list-icon-wrapper-padding` / `-border` / `-radius` / `-background` | none | Circle or tile behind the icon |
113
+ | `--social-icons-list-label-colour` / `-font-size` / `-font-weight` | `currentColor` / `--step-4` / `600` | Visible label |
93
114
  | `--social-icons-list-hover-scale` | `1.15` | Hover/focus scale (none under reduced motion) |
94
115
  | `--social-icons-list-hover-opacity` | `0.85` | Hover/focus opacity |
116
+ | `--social-icons-list-focus-ring-colour` | `var(--theme-ring, currentColor)` | Keyboard focus outline |
117
+
118
+ Full table with defaults in `CONSUMER-STYLING.md`. **Tokens that read `--social-icons-list-brand-colour` must be set on `.social-icons-list-item`**, where the brand colour is defined: a `var()` in a custom property resolves on the declaring element, so set on the list root it finds nothing. Four recipes (mono tiles with brand hover, dark dock with glow, labelled pills, overlapping stack) are in `stories/social-icons-list-recipes.css` and the `Recipe: …` stories; reuse them rather than inventing new structure.
95
119
 
96
- > Renamed 2026-09-28 from `--theme-social-icon-size` / `--theme-social-icon-gap`. Full reference: `CONSUMER-STYLING.md` in the component folder.
120
+ > Renamed 2026-09-28 from `--theme-social-icon-size` / `--theme-social-icon-gap`. Inner classes renamed 2026-10-16 (`.social-icon-item`/`-link`/`.social-icon` are now `.social-icons-list-item`/`-link`/`-icon`). Full reference: `CONSUMER-STYLING.md` in the component folder.
97
121
 
98
- > **Note:** Iconify's `logos:*` styles are injected outside any CSS `@layer`, which means they override layered component styles. Icon sizing is applied via an inline `style` attribute on the `<Icon>` element so it takes precedence.
122
+ The size token is a `font-size` on `.social-icons-list-icon-wrapper`, whose content box is a fixed `1em` square (`box-sizing: content-box`, padding added around it). The default icon fills it, any `icon` slot content is centred in it, and an icon name that doesn't resolve still leaves a visible, focusable target. With uniform `--social-icons-list-icon-wrapper-padding` and a `50%` radius it's always a circle; slot content bigger than `1em` overflows the square, so size it to fit (e.g. `font-size: 0.8em` for a letter).
99
123
 
100
124
  ---
101
125
 
@@ -120,7 +144,7 @@ Offer this scaffold when placing the component in a consuming page or section. T
120
144
  /* --social-icons-list-gap: 1.6rem; */
121
145
  /* margin-block-start: 1.6rem; */
122
146
 
123
- .social-icon-link {
147
+ .social-icons-list-link {
124
148
  /* border-radius: 0.4rem; */
125
149
  /* padding: 0.4rem; */
126
150
  /* outline: 1px solid transparent; */
@@ -138,6 +162,9 @@ need to target a specific instance. See `component-local-style-override.md` for
138
162
  ## Notes
139
163
 
140
164
  - All links open in a new tab with `rel="noopener noreferrer"`.
165
+ - An item is skipped (with a dev-mode `console.warn`) when its `baseHref + profileId` isn't `http:`/`https:`, `mailto:`, `tel:` or a site-relative `/path`: `items` often comes from a CMS, and a `javascript:` href would otherwise render as a working link. `mailto:` and `tel:` items are fine, so email and phone can sit in the same row.
166
+ - Keys are `index + networkName`, so two accounts on the same network (two Instagram profiles) both render.
167
+ - The `<ul>` has `role="list"`: with `list-style: none`, Safari/VoiceOver stops announcing it as a list otherwise.
141
168
  - Each link's `aria-label` comes from `linkLabelTemplate` (default `"{network} profile (opens in a new tab)"`, `{network}` = `networkName`), or the item's own `label`. Pass a translated template for other languages. The icon is `aria-hidden`.
142
169
  - The `logos:*` Iconify collection requires `@iconify-json/logos` to be installed in the consumer app. Without it, icons will fall back to a CDN fetch (causing FOUC). See [icon-sets.md](../icon-sets.md).
143
170
  - Auto-imported in Nuxt — no manual import needed.
@@ -0,0 +1,114 @@
1
+ # CSS Dynamic Border Radius
2
+
3
+ ## Overview
4
+
5
+ `.dynamic-border-radius` (global utility, `app/assets/styles/setup/06.utility-classes/_dynamic-border-radius.css`,
6
+ `utilities` layer) rounds an element's corners while it's narrower than its container, and squares them once it
7
+ spans the full container width. A capped-width image reads as a rounded card on wide screens and as an edge-to-edge
8
+ band on narrow ones, with no media query and no JS.
9
+
10
+ ```css
11
+ .dynamic-border-radius {
12
+ border-radius: clamp(0px, (100cqi - 100%) * 1e5, var(--dynamic-border-radius, 5rem));
13
+ }
14
+
15
+ .dynamic-border-radius-transition {
16
+ max-inline-size: var(--dynamic-border-radius-max-width, 100%);
17
+ border-radius: clamp(0px, (100cqi - var(--dynamic-border-radius-max-width, 100%)) * 1e5, var(--dynamic-border-radius, 5rem));
18
+
19
+ @media (prefers-reduced-motion: no-preference) {
20
+ transition: border-radius var(--dynamic-border-radius-duration, 300ms) var(--dynamic-border-radius-easing, ease-out);
21
+ }
22
+ }
23
+ ```
24
+
25
+ It's a utility class, not a component: it works on any element (`<img>`, `NuxtImg`, `<video>`, a card), and the
26
+ container it measures is an ancestor the element doesn't own.
27
+
28
+ Storybook: **Foundations/Utilities/Dynamic Border Radius**.
29
+
30
+ ## Token
31
+
32
+ | Token | Default | Controls |
33
+ |---|---|---|
34
+ | `--dynamic-border-radius` | `5rem` | Radius while the element is narrower than its container. Set it on the element or any ancestor. |
35
+ | `--dynamic-border-radius-max-width` | `100%` | `.dynamic-border-radius-transition` only: the element's max width, as a length. Sets its `max-inline-size` and is what the container is compared against. |
36
+ | `--dynamic-border-radius-duration` | `300ms` | Transition duration, with `.dynamic-border-radius-transition`. |
37
+ | `--dynamic-border-radius-easing` | `ease-out` | Transition easing, with `.dynamic-border-radius-transition`. |
38
+
39
+ ## Usage
40
+
41
+ ```html
42
+ <div class="hero-media">
43
+ <img class="dynamic-border-radius" src="/images/hero.jpg" alt="" width="1600" height="900">
44
+ </div>
45
+ ```
46
+
47
+ ```css
48
+ .hero-media {
49
+ container-type: inline-size;
50
+
51
+ img {
52
+ display: block;
53
+ inline-size: 100%;
54
+ max-inline-size: 50rem;
55
+ margin-inline: auto;
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## How it works
61
+
62
+ `100cqi` is the container's inline size; inside `border-radius`, `100%` is the element's own width. While the element
63
+ is narrower, the difference is positive, the `* 1e5` blows it up past the max, and `clamp()` caps it at the token.
64
+ When the element fills the container, the difference is 0 and so is the radius.
65
+
66
+ ## Optional transition
67
+
68
+ Use `.dynamic-border-radius-transition` instead of the base class to ease the switch, and give it the element's max
69
+ width as a token (it sets `max-inline-size` from it, so don't also set `max-inline-size` yourself):
70
+
71
+ ```html
72
+ <img class="dynamic-border-radius-transition" style="--dynamic-border-radius-max-width: 50rem" src="/images/hero.jpg" alt="" width="1600" height="900">
73
+ ```
74
+
75
+ - **Why a different formula.** Don't just add a `transition` to the base class: it flickers while the container
76
+ shrinks. A transition starts from the old *computed* value, where `100cqi` is already pixels (the previous
77
+ frame's container width) but `100%` stays unresolved until layout, so it's re-evaluated against this frame's,
78
+ smaller, element width. Previous container minus current element is positive, `* 1e5` clamps it to the full
79
+ radius, and every resize frame restarts a transition from there. Comparing against a length token instead makes
80
+ the computed value a plain length (full radius or 0): nothing changes while you resize either side of the
81
+ threshold, and crossing it starts exactly one transition.
82
+ - **Without the token** it falls back to `100%`: the base formula, so it works but flickers on resize.
83
+ - **The token must match the element's real max width**, as a single length (`50rem`, not `min(50rem, 90%)`). If
84
+ something else caps the element narrower (a consumer `max-inline-size`, a small intrinsic image size), the radius
85
+ stays at 0 even though the element doesn't fill the container.
86
+ - It sets `transition`, which replaces any transition already on the element (the utilities layer beats
87
+ `@layer components`). If the element needs its own transition too, add `border-radius` to your own combined
88
+ `transition` instead (`transform 200ms, border-radius 300ms ease-out`).
89
+ - It only animates when the **container** resizes. If only the token changes (at a breakpoint), the value changes
90
+ too, so that animates as well; an image loading doesn't affect it.
91
+ - The transition is skipped under `prefers-reduced-motion: reduce`; the radius still switches.
92
+
93
+ ## Caveats
94
+
95
+ 1. **It needs a `container-type: inline-size` ancestor.** Without one, `cqi` falls back to the viewport, so the
96
+ radius only drops when the element is as wide as the screen. It doesn't error; it just looks wrong. Put the
97
+ container on the element's direct layout wrapper: if the nearest container is wider than the element's real
98
+ column, the element never "fills" it.
99
+ 2. **It works partly by luck. Don't split it into horizontal and vertical radii.** The single value sets both radii, and
100
+ the vertical one resolves `100%` against the element's *height*, so at full width it's usually still at the max.
101
+ The corner is square anyway, because CSS draws a square corner when either radius is 0. A `border-radius: X / Y`
102
+ rewrite, or a percentage max, can break this.
103
+ 3. **The element must actually reach full width.** Give an image `display: block; inline-size: 100%` plus a
104
+ `max-inline-size`. With only a max, an image whose intrinsic width is smaller than the container (a `w=400` CDN
105
+ URL, for example) never fills it, so the radius never drops.
106
+ 4. **Subpixel widths.** A 500.4px container around a 500px element keeps the radius on. That's correct (it isn't
107
+ full width), but it can look like the switch is late at fractional zoom levels.
108
+ 5. **Support:** container query units and `clamp()` with mixed units work in Safari 16, the layer's support
109
+ baseline.
110
+
111
+ ## Snippet
112
+
113
+ `.vscode/srcdev-utility-dynamic-border-radius.code-snippets`: `srcdev-dynamic-border-radius` (markup with the
114
+ container wrapper, transition class as an optional placeholder) and `srcdev-dynamic-border-radius-css` (the wrapper and image rules).
@@ -51,6 +51,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
51
51
  ├── css-nesting-conventions.md — native CSS nesting rules: why &__child Sass BEM concatenation silently breaks, correct patterns
52
52
  ├── css-grid-max-width-gutters.md — cap a centre grid column width by growing gutters, with start/center alignment variants
53
53
  ├── css-animation-utilities.md — scroll-driven animation utility classes: scroller-x (carousel), entry-zoom-reveal, entry-slide-in, entry-exit-blur, auto-rotate
54
+ ├── css-dynamic-border-radius.md — .dynamic-border-radius utility: rounded until the element fills its container-type ancestor, then square; --dynamic-border-radius token, opt-in -transition class, container/full-width caveats
54
55
  ├── component-aria-landmark.md — useAriaLabelledById composable: aria-labelledby for section/article/aside tags (not main), consumer-bound vs self-bound pattern, built-in broken-reference console warning
55
56
  ├── component-export-types.md — move inline component types to app/types/components/ barrel for consumer imports
56
57
  ├── component-inline-action-button.md — InputButton variant="inline" pattern for buttons embedded in custom input wrappers
@@ -87,7 +88,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
87
88
  ├── link-text.md — LinkText props, slots, usage patterns, styling
88
89
  ├── header-block.md — HeaderBlock: tagLevel/classLevel semantic-vs-visual heading decoupling, page-heading-N utility classes
89
90
  ├── text-block.md — TextBlock: vertical-rhythm text wrapper, --text-block-padding-block-start/-end tokens, heading-id slot prop for section/article aria-labelledby
90
- ├── page-hero-highlights.md — PageHeroHighlights template: hero + highlights strip grid, CSS custom property theming
91
+ ├── page-hero-highlights.md — PageHeroHighlights template: hero + straddling highlights strip grid (root is the `page-hero-highlights` container, grid in `.page-hero-highlights-grid`), widthConstrained caps the column, equal-width highlights wrap at --page-hero-highlights-item-min-width, light theme-token defaults, PageHeroHighlightsHeader companion
91
92
  ├── slider-gallery.md — SliderGallery: full-screen image carousel, IGalleryData, pause-on-hover/reduced-motion auto-advance, prefixed classes, CSS tokens
92
93
  ├── services-card.md — ServicesCard props (incl. eyebrowConfig/heroConfig), actions slot, CSS tokens, page boilerplate
93
94
  ├── google-reviews.md — GoogleReviews + GoogleReviewCard: Google Places reviews in a scrolling row; env vars, server route, useGoogleReviews, props, slots, tokens, Google terms
@@ -115,7 +116,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
115
116
  ├── 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
116
117
  ├── marquee-scroller.md — MarqueeScroller: infinite logo/badge scroller, per-item dynamic slots (marqueeData id), hover/focus/keyboard pause, reduced-motion, CSS tokens
117
118
  ├── rotating-carousel-image.md — RotatingCarouselImage: 3D rotating image carousel, scroll-parallax tilt, focus/keyboard/hover pause, reduced-motion, CSS tokens
118
- ├── social-icons-list.md — SocialIconsList: data-driven social icon links, ISocialIcon type, logos: icon names (dark ones vanish on dark backgrounds; mono alternatives), CSS tokens incl. --social-icons-list-icon-colour
119
+ ├── social-icons-list.md — SocialIconsList: data-driven social icon links, ISocialIcon type, logos: icon names (dark ones vanish on dark backgrounds; mono alternatives), showLabel + scoped icon/label slots, per-item brandColour (set brand-colour tokens on .social-icons-list-item), tile/circle/label tokens, four recipes (mono tiles, dark dock, pills, overlapping stack), unsafe hrefs skipped
119
120
  ├── display-qr-code.md — DisplayQrCode: QR code SVG from a string value, colour/size/variant/radius props, currentColor default
120
121
  ├── capture-qr-code.md — CaptureQrCode: live camera scanner, error state, visibility/route/KeepAlive lifecycle, media stream cleanup
121
122
  ├── decode-qr-code.md — DecodeQrCode: file picker + drag-and-drop image decoder, shared results list, CSS override points
@@ -321,6 +321,12 @@ the earlier nightly `@nuxtjs/storybook` didn't render Docs pages at all, so a lo
321
321
  screens, and sets `docs.story: { inline: false }`. `CookieConsentBanner` and `DisplayToast`
322
322
  follow this. A teleported component doesn't inherit a wrapper's `dir`, so wrap RTL slot copy
323
323
  in `<span dir="rtl">`.
324
+ - The same goes for tokens: a teleported component doesn't inherit custom properties from a story
325
+ wrapper. To demo a token override, inject a `<style>` rule targeting the component's own root
326
+ class in setup (remove it in `onUnmounted`); that also keeps `var(--theme-*)` inside the value
327
+ resolving against the component's own `data-theme`. A backdrop meant to sit behind it (to demo
328
+ translucency) must be `position: fixed; inset: 0`. `DisplayToast`'s `canvas` / `maskedFill`
329
+ story controls follow this.
324
330
  - Opt a file out with `tags: ["!autodocs"]` only when it isn't a component reference (tooling, or
325
331
  a file with its own attached `.mdx` page, like `ColourRamps.stories.ts` with `SetupGuide.mdx`).
326
332
 
@@ -62,16 +62,14 @@
62
62
  ]
63
63
  },
64
64
  "SRCDEV DisplayToast CSS Override": {
65
- "description": "CSS override scaffold for DisplayToastProvider items",
65
+ "description": "CSS override scaffold for DisplayToastProvider items (teleported to body, so not nested in a page class)",
66
66
  "scope": "css",
67
67
  "body": [
68
- ".$1my-page {",
69
- " .display-toast-provider-item {",
70
- " --theme-surface: ;",
71
- " --theme-text: ;",
72
- " --theme-accent: ;",
73
- " --theme-border: ;",
74
- " }",
68
+ ".display-toast-provider-item {",
69
+ " --theme-surface: $1;",
70
+ " --theme-text: $2;",
71
+ " --theme-accent: $3;",
72
+ " --theme-border: $4;",
75
73
  "}"
76
74
  ]
77
75
  }
@@ -3,7 +3,7 @@
3
3
  "description": "PageHeroHighlights: header zone, straddling highlights strip, content panel",
4
4
  "scope": "vue,html",
5
5
  "body": [
6
- "<PageHeroHighlights tag=\"section\" content-align=\"${1|center,start|}\">",
6
+ "<PageHeroHighlights tag=\"section\" :width-constrained=\"true\" content-align=\"${1|center,start|}\">",
7
7
  " <template #header=\"{ headingId }\">",
8
8
  " <PageHeroHighlightsHeader>",
9
9
  " <template #start><h1 :id=\"headingId\">$2</h1></template>",
@@ -31,6 +31,7 @@
31
31
  " --page-hero-highlights-content-background: $3;",
32
32
  " --page-hero-highlights-item-background: $4;",
33
33
  " --page-hero-highlights-max-width: $5;",
34
+ " --page-hero-highlights-item-min-width: $6;",
34
35
  "}"
35
36
  ]
36
37
  }
@@ -11,6 +11,19 @@
11
11
  "/>"
12
12
  ]
13
13
  },
14
+ "SRCDEV SocialIconsList Labelled": {
15
+ "description": "SocialIconsList with visible labels and brand colours (mono icons, for the pill or tile recipes)",
16
+ "scope": "vue,html",
17
+ "body": [
18
+ "<SocialIconsList",
19
+ " :items=\"[",
20
+ " { networkName: '${1:Instagram}', iconName: '${2:bi:instagram}', baseHref: '${3:https://www.instagram.com/}', profileId: '$4', brandColour: '${5:#e1306c}' },",
21
+ " ]\"",
22
+ " show-label",
23
+ " :style-class-passthrough=\"['${6:my-social-pills}']\"",
24
+ "/>"
25
+ ]
26
+ },
14
27
  "SRCDEV SocialIconsList CSS Override": {
15
28
  "description": "CSS override scaffold for SocialIconsList tokens",
16
29
  "scope": "css",
@@ -18,6 +31,7 @@
18
31
  ".$1my-page {",
19
32
  " --social-icons-list-icon-size: $2;",
20
33
  " --social-icons-list-gap: $3;",
34
+ " --social-icons-list-icon-colour: $4;",
21
35
  "}"
22
36
  ]
23
37
  }
@@ -0,0 +1,31 @@
1
+ {
2
+ "SRCDEV Dynamic Border Radius Markup": {
3
+ "prefix": "srcdev-dynamic-border-radius",
4
+ "description": ".dynamic-border-radius: rounded until the image fills its container, then square (wrapper needs container-type: inline-size)",
5
+ "scope": "vue,html",
6
+ "body": [
7
+ "<div class=\"$1media-wrapper\">",
8
+ " <img class=\"dynamic-border-radius${6:-transition}\" src=\"$2\" alt=\"$3\" width=\"$4\" height=\"$5\">",
9
+ "</div>"
10
+ ]
11
+ },
12
+ "SRCDEV Dynamic Border Radius CSS": {
13
+ "prefix": "srcdev-dynamic-border-radius-css",
14
+ "description": "Container wrapper and image rules for .dynamic-border-radius (max width via the token, so the -transition class can read it)",
15
+ "scope": "css",
16
+ "body": [
17
+ ".$1media-wrapper {",
18
+ " container-type: inline-size;",
19
+ "",
20
+ " img {",
21
+ " display: block;",
22
+ " inline-size: 100%;",
23
+ " --dynamic-border-radius-max-width: ${2:50rem};",
24
+ " max-inline-size: var(--dynamic-border-radius-max-width);",
25
+ " margin-inline: auto;",
26
+ " --dynamic-border-radius: ${3:5rem};",
27
+ " }",
28
+ "}"
29
+ ]
30
+ }
31
+ }
@@ -0,0 +1,27 @@
1
+ /* ===========================================
2
+ DYNAMIC BORDER RADIUS
3
+ Rounded corners while the element is narrower than its
4
+ container; square once it spans the full container width.
5
+ Needs an ancestor with container-type: inline-size.
6
+ =========================================== */
7
+
8
+ .dynamic-border-radius {
9
+ border-radius: clamp(0px, (100cqi - 100%) * 1e5, var(--dynamic-border-radius, 5rem));
10
+ }
11
+
12
+ /*
13
+ * Transition variant: compares against a max-width token instead of 100%, so the computed value is a plain length.
14
+ * A % stays unresolved until layout, which makes a transition start from a stale value and flicker on every resize frame.
15
+ */
16
+ .dynamic-border-radius-transition {
17
+ max-inline-size: var(--dynamic-border-radius-max-width, 100%);
18
+ border-radius: clamp(
19
+ 0px,
20
+ (100cqi - var(--dynamic-border-radius-max-width, 100%)) * 1e5,
21
+ var(--dynamic-border-radius, 5rem)
22
+ );
23
+
24
+ @media (prefers-reduced-motion: no-preference) {
25
+ transition: border-radius var(--dynamic-border-radius-duration, 300ms) var(--dynamic-border-radius-easing, ease-out);
26
+ }
27
+ }
@@ -2,3 +2,4 @@
2
2
  @import "./spacing/index.css";
3
3
  /* @import "./layout/"; */
4
4
  @import "./_a11y.css";
5
+ @import "./_dynamic-border-radius.css";