srcdev-nuxt-components 9.6.1 → 9.6.2

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 (27) hide show
  1. package/.claude/skills/component-compliance-checklist.md +14 -1
  2. package/.claude/skills/components/display-avatar.md +14 -3
  3. package/.claude/skills/components/display-chip.md +2 -1
  4. package/.claude/skills/components/google-reviews.md +2 -2
  5. package/.claude/skills/components/profile-section.md +59 -21
  6. package/.claude/skills/components/social-icons-list.md +2 -0
  7. package/.claude/skills/index.md +4 -3
  8. package/.claude/skills/storybook-add-story.md +2 -0
  9. package/.claude/skills/theming-component-token-pattern.md +48 -0
  10. package/.vscode/srcdev-component-display-avatar.code-snippets +7 -0
  11. package/.vscode/srcdev-component-profile-section.code-snippets +27 -4
  12. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +26 -10
  13. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +75 -34
  14. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +84 -3
  15. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +85 -13
  16. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +3 -3
  17. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +7 -2
  18. package/app/components/02.molecules/profile-section/CONSUMER-STYLING.md +103 -13
  19. package/app/components/02.molecules/profile-section/ProfileSection.vue +123 -23
  20. package/app/components/02.molecules/profile-section/stories/ProfileSection.stories.ts +353 -115
  21. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +144 -11
  22. package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap +6 -11
  23. package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +6 -2
  24. package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +1 -0
  25. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +1 -1
  26. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +6 -6
  27. package/package.json +1 -1
@@ -101,6 +101,10 @@ The ledger can't see items 9, 10, 11 and 14, so check those by hand.
101
101
  If other components render this one inside them (it's a child or building block of theirs),
102
102
  each story gets a short note that explains the relationship and links to the parent's story
103
103
  (`storybook-add-story.md`, "Link to the stories of components that use this one").
104
+ Sample copy in stories, and examples in the skill doc, use placeholders (a name like "Alex
105
+ Morgan", a town like "Northtown"), never a real client's name, town, business or site: the
106
+ stories and docs ship in the public package. **Existing component:** check for, and replace, any
107
+ copy lifted from a consumer app (`ProfileSection` had both).
104
108
  6a. **Storybook Controls-panel reactivity** — check every story `Template`/`render` function in
105
109
  the component's `stories/*.stories.ts` files for this bug: `@storybook/vue3` mounts the story
106
110
  component **once** and, on every Controls-panel change, mutates the same reactive `args`
@@ -134,7 +138,9 @@ The ledger can't see items 9, 10, 11 and 14, so check those by hand.
134
138
  descriptions, body text) a line-clamp token, `--<component>-<element>-line-clamp`, which
135
139
  covers ellipsis too (`1` is single-line ellipsis). Default it to `none` unless the layout
136
140
  clearly needs a cap, and never clamp text that must stay readable (legal or attribution text,
137
- link text that names its destination). Shape and rules: `theming-component-token-pattern.md`,
141
+ link text that names its destination). Only text the component renders itself (props and data) needs a token: text that arrives
142
+ through a slot is the consumer's own markup, so they clamp it directly and the component adds
143
+ no token for it (`ProfileSection` is all slots and has none). Shape and rules: `theming-component-token-pattern.md`,
138
144
  "Line-clamp tokens". Give each line-clamp token a Controls-panel select in the stories (set on
139
145
  a wrapper `:style`, stripped from the component's args), labelled as a story-only CSS token so
140
146
  nobody mistakes it for a prop: `storybook-add-story.md`, "Label token controls as story-only".
@@ -222,3 +228,10 @@ The ledger can't see items 9, 10, 11 and 14, so check those by hand.
222
228
  - `NuxtImg` gets explicit `width` and `height`; a `<video>` uses a `<source :src>` child with
223
229
  `:key` and `.load()` (pitfall #12).
224
230
  - No `#components` import (breaks Storybook); use `resolveComponent()`.
231
+ - Layout breakpoints respond to the component's own width (`container: <name> / inline-size` on the
232
+ root, `@container <name> (min-width: …)`), not the viewport, so it still works in a narrow
233
+ column. **Existing component:** when converting a viewport `@media` breakpoint, grep consumer
234
+ repos for `@media` overrides of the same element (they now switch at a different point) and list
235
+ the edits in private memory. Note that an inline-size container can't size itself from its
236
+ content: say so in `CONSUMER-STYLING.md` (`ProfileSection`).
237
+ - Grid tracks that hold text are `minmax(0, …)` so an unbroken string can't widen the grid.
@@ -1,5 +1,7 @@
1
1
  # DisplayAvatar Component
2
2
 
3
+ > **Changed 2026-10-14:** size is a `data-size` attribute, not a bare class (`.display-avatar.lg` → `.display-avatar[data-size="lg"]`). New `image-size` prop (default `112`): `NuxtImg` used to get `width="100%"`, which @nuxt/image ignores, so the Vercel provider requested its largest screen width for every avatar. A broken image now falls back to the initials, initials are emoji-safe, and long fallback text is clipped inside the circle. With `chip`, the status dot was drawn at the avatar's diameter instead of its configured size: avatar and chip both declared a private `--_size` on the same root element and the avatar's size rules won. The avatar's private tokens are now `--_avatar-size`/`--_avatar-font-size`. Content (both slots too) now sits in a full-size `.display-avatar-surface` that carries the background, so the chip's cutout covers the whole avatar instead of landing on the initials span and cutting into the letters.
4
+
3
5
  > **Changed 2026-10-08:** new `status`/`status-label` props, forwarded to the chip. Chip status used to be an `online`/`idle`/`dnd` class through `style-class-passthrough`, which no longer colours the dot.
4
6
 
5
7
  ## Overview
@@ -18,10 +20,11 @@
18
20
  | `src` | `string` | — | Image URL. Renders `NuxtImg` when set; fallback text otherwise. |
19
21
  | `alt` | `string` | — | Alt text for the image; also used to derive initials. |
20
22
  | `text` | `string` | — | Override the auto-derived initials with an explicit string. |
21
- | `size` | `"xs" \| "s" \| "md" \| "lg" \| "xl"` | `"md"` | Controls width, height, and font-size. |
23
+ | `size` | `"xs" \| "s" \| "md" \| "lg" \| "xl"` | `"md"` | Sets `data-size`, which picks width, height and font-size. |
22
24
  | `chip` | `boolean \| DisplayChipConfig` | — | Add a status chip. `true` uses defaults; pass a config object to customise. |
23
25
  | `status` | `DisplayChipStatus` | — | Chip status colour (`offline`/`online`/`idle`/`dnd`), forwarded to `DisplayChip`. Ignored without `chip`; the chip defaults to `offline`. |
24
26
  | `status-label` | `string` | — | Screen-reader text for the chip status (e.g. "Online"), forwarded to `DisplayChip`. Ignored without `chip`. |
27
+ | `:image-size` | `number` | `112` | Pixel width and height requested from `@nuxt/image` (1x; 2x is double). Raise it if you resize the avatar past `xl` with `--display-avatar-size`. |
25
28
  | `:style-class-passthrough` | `string \| string[]` | `[]` | Extra CSS classes on the root element. |
26
29
 
27
30
  ### Size dimensions
@@ -45,24 +48,32 @@ Each step is a token (`--display-avatar-size-md`, `--display-avatar-font-size-md
45
48
  | `default` | Replaces the auto image/fallback content entirely. |
46
49
  | `icon` | Appended inside the avatar (e.g. an icon overlay over the image). |
47
50
 
51
+ Both slots render inside `.display-avatar-surface`, a full-size element that carries the background. With `chip`, that surface is what gets the cutout mask.
52
+
48
53
  ---
49
54
 
50
55
  ## Fallback text logic
51
56
 
52
57
  When `src` is not set, a `<span>` renders the fallback value:
53
58
 
54
- 1. `text` prop — used as-is if provided.
55
- 2. `alt` initials — first character of each word, capped at two characters.
59
+ 1. `text` prop — used as-is if it has any non-whitespace content.
60
+ 2. `alt` initials — the first character (a whole grapheme, so emoji and flags stay intact) of the first two words. Extra whitespace is ignored.
56
61
  3. Empty string — if neither is set.
57
62
 
58
63
  When `alt` is set, the initials are `aria-hidden` and the full `alt` is rendered as `sr-only` text, so a screen reader announces "Jane Smith" rather than "J S". With only `text`, the text is read as-is.
59
64
 
65
+ If the image fails to load, the initials fallback shows instead (an empty circle when there is no `alt`). A new `src` tries the image again.
66
+
67
+ The fallback is one line, clipped to the circle: keep `text` short (1–3 characters).
68
+
60
69
  With `src`, the image uses `alt`, or an empty (decorative) `alt` when none is given. Pass `alt` unless the name is already visible next to the avatar.
61
70
 
62
71
  ```
63
72
  alt="John Doe" → "JD"
64
73
  alt="Alice" → "A"
65
74
  alt="Alice Bob C" → "AB"
75
+ alt="🦄 Unicorn" → "🦄U"
76
+ alt=" Jane Smith " → "JS"
66
77
  text="?" → "?"
67
78
  ```
68
79
 
@@ -226,6 +226,7 @@ See [display-avatar.md](./display-avatar.md) for the full API.
226
226
  - `shape` must match the actual shape of the slot content — the position maths differs between `circle` (radius-based) and `square` (clamped corner-aware).
227
227
  - `config` values are geometric inputs to CSS `calc(cos())` / `calc(sin())` expressions. Pass them as strings with units (`"12px"`, `"45deg"`), not plain numbers.
228
228
  - The chip dot is rendered via `::after` pseudo-element; icon and label sit above it at `z-index: 2`.
229
- - The mask cutout is applied to all direct children of `.display-chip` except `.display-chip-icon`, `.display-chip-label` and the `.sr-only` status text — ensure the host element is a direct child.
229
+ - The mask cutout is applied to all direct children of `.display-chip` except `.display-chip-icon`, `.display-chip-label` and the `.sr-only` status text. The mask is positioned in percentages of each child's own box, so slot exactly one host element that fills the chip and carries its own background (an `<img>`, or a full-size wrapper around text). A small text span as a direct child gets the notch cut into its letters, and a background set on `.display-chip` itself is never cut. `DisplayAvatar` wraps its content in a full-size `.display-avatar-surface` for this reason.
230
+ - A component that renders `DisplayChip` as its own root (as `DisplayAvatar` does) shares that element, so it must not declare the chip's private tokens (`--_size`, `--_offset`, ...) for its own values: they collide on the same element. `DisplayAvatar` uses `--_avatar-size`.
230
231
  - The `StressTest` story shows the worst-case labels and config values.
231
232
  - `DisplayChipConfig` and `DisplayChipProps` are both exported from the layer types. Use `DisplayChipConfig` when passing geometry values (the `config` prop). Use `DisplayChipProps` only if you need to pass the full component prop set (e.g. when building a wrapper component).
@@ -132,8 +132,8 @@ replaced.
132
132
  - Survives hostile data (see the `StressTest` story): long and unbroken text wraps inside its card,
133
133
  reviewer names are cut to 2 lines (`--google-reviews-author-line-clamp`),
134
134
  initials take whole characters (emoji-safe), star fill is clamped to 0–5, review text renders as
135
- plain text. A broken photo URL still shows the browser's broken-image icon, since
136
- `DisplayAvatar` has no image-error fallback.
135
+ plain text. A broken photo URL falls back to the reviewer's initials (`DisplayAvatar`'s image-error
136
+ fallback).
137
137
 
138
138
  ## Variants
139
139
 
@@ -1,24 +1,26 @@
1
1
  ---
2
2
  name: ProfileSection
3
- description: ProfileSection molecule — props, dynamic profile-info slots, eyebrowText/heroText/profileLinks slots, layout, accessibility
3
+ description: ProfileSection molecule — props, dynamic profile-info slots, eyebrowText/heroText/profileLinks slots, container-query layout, accessibility
4
4
  type: reference
5
5
  ---
6
6
 
7
7
  # ProfileSection
8
8
 
9
+ > **Changed 2026-10-14 (breaking):** New `headerPlacement` prop (`above`, `picture-bottom`, `picture-top`) can put the header over the bottom or top of the picture. `profileInfoCount` removed — only the `profile-info-N` slots you give are rendered (it used to pad with empty blocks when none were given). The two-column switch is a container query on the component's own width, not a viewport `@media` query, and both columns are `minmax(0, …)`. The `<header>` is only rendered when a header slot is given. Highlight colours go through new `--profile-section-*` tokens. An invalid `profilePicture.width`/`height` (0, negative, `NaN`) falls back to 828×1104.
10
+ >
9
11
  > **Classes renamed 2026-09-27:** `.picture` → `.profile-section-picture`, `.profile-picture` → `.profile-section-image`, `.profile-info` → `.profile-section-info`, `.profile-info-content` → `.profile-section-info-content`, `.profile-info-block` → `.profile-section-info-block`, `.profile-links` → `.profile-section-links`.
10
12
 
11
13
  ## Overview
12
14
 
13
- `ProfileSection` is a molecule that renders a practitioner/author profile: a header area (eyebrow + heading), a profile picture, and a flexible set of bio/info blocks alongside optional profile links. It is landmark-aware — when `tag` is `section`, `article`, or `aside`, the root element automatically gets `aria-labelledby` wired to the heading inside the `#heroText` slot (**you must bind `headingId` yourself, and a console warning fires if you forget** — see [component-aria-landmark.md](../component-aria-landmark.md)). `tag="main"` is never auto-labelled.
15
+ `ProfileSection` is a molecule that renders a practitioner/author profile: a header area (eyebrow + heading), a profile picture, and a flexible set of bio/info blocks alongside optional profile links. It is landmark-aware — when `tag` is `section` or `article`, the root element automatically gets `aria-labelledby` wired to the heading inside the `#heroText` slot (**you must bind `headingId` yourself, and a console warning fires if you forget** — see [component-aria-landmark.md](../component-aria-landmark.md)). `tag="main"` is never auto-labelled.
14
16
 
15
17
  ## Props
16
18
 
17
19
  | Prop | Type | Default | Description |
18
20
  |------|------|---------|-------------|
19
- | `profilePicture` | `ProfilePicture` (`{ src; alt; width?; height? }`, exported from the layer types) | — | **Required.** Rendered via `NuxtImg` with `width`/`height` defaulting to `828`/`1104` (3:4, a Vercel-allowed width). |
21
+ | `profilePicture` | `ProfilePicture` (`{ src; alt; width?; height? }`, exported from the layer types) | — | **Required.** Rendered via `NuxtImg`. `width`/`height` default to `828`/`1104` (3:4, a Vercel-allowed width), and a missing or invalid value (0, negative, `NaN`, `Infinity`) uses the default. |
20
22
  | `tag` | `"div" \| "section" \| "article" \| "main"` | `"div"` | HTML element rendered as the root. |
21
- | `profileInfoCount` | `number` | `3` | Number of `profile-info-N` slots to generate when none are explicitly provided. |
23
+ | `headerPlacement` | `"above" \| "picture-bottom" \| "picture-top"` | `"above"` | Where the `#eyebrowText`/`#heroText` header goes: above the grid, or over the bottom or top of the picture on a scrim. Template form: `header-placement`. |
22
24
  | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes applied to the root element. |
23
25
 
24
26
  ## Slots
@@ -28,17 +30,19 @@ type: reference
28
30
  | `#eyebrowText` | Optional eyebrow label above the heading. Use `EyebrowText`. |
29
31
  | `#heroText` | Heading slot. Receives `headingId` as a slot prop — bind it to the heading's `id` for accessible `aria-labelledby`. |
30
32
  | `#profile-info-1` … `#profile-info-N` | Bio / info blocks. Provide as many numbered slots as needed. They are rendered in ascending numeric order. |
31
- | `#profileLinks` | Links / actions shown at the bottom-right of the info column (e.g. social icons, booking CTA). |
33
+ | `#profileLinks` | Links / actions shown at the bottom-right of the info column (e.g. social icons, booking CTA). Wraps onto more lines when there are many. |
34
+
35
+ The `<header class="profile-section-header">` is only rendered when `#eyebrowText` or `#heroText` is given. It renders above the grid, or inside the picture when `header-placement` is `picture-bottom` or `picture-top`.
32
36
 
33
37
  ## Basic usage
34
38
 
35
39
  ```vue
36
40
  <ProfileSection
37
41
  tag="section"
38
- :profile-picture="{ src: '/images/profile/mel.jpg', alt: 'Mel Stafford, reflexologist' }"
42
+ :profile-picture="{ src: '/images/profile/alex.jpg', alt: 'Alex Morgan, hair stylist' }"
39
43
  >
40
44
  <template #eyebrowText>
41
- <EyebrowText font-size="large" text="About Mel" />
45
+ <EyebrowText font-size="large" text-content="About Alex" />
42
46
  </template>
43
47
 
44
48
  <template #heroText="{ headingId }">
@@ -47,8 +51,8 @@ type: reference
47
51
  :id="headingId"
48
52
  font-size="title"
49
53
  :text-content="[
50
- { text: 'Mel Stafford', styleClass: 'normal' },
51
- { text: 'Reflexologist', styleClass: 'accent' },
54
+ { text: 'Alex Morgan', styleClass: 'normal' },
55
+ { text: 'Hair stylist', styleClass: 'accent' },
52
56
  ]"
53
57
  />
54
58
  </template>
@@ -58,7 +62,7 @@ type: reference
58
62
  </template>
59
63
 
60
64
  <template #profile-info-2>
61
- <p>Mel is based in Lichfield…</p>
65
+ <p class="location">Based in <span class="highlight">Northtown</span>…</p>
62
66
  </template>
63
67
 
64
68
  <template #profileLinks>
@@ -69,11 +73,12 @@ type: reference
69
73
 
70
74
  ## Dynamic profile-info slots
71
75
 
72
- The `profile-info-N` slots are discovered at runtime by filtering `useSlots()` for keys matching `/^profile-info-\d+$/`. The slots are rendered in ascending numeric order.
76
+ The `profile-info-N` slots are discovered at runtime by filtering `useSlots()` for keys matching `/^profile-info-\d+$/`, and rendered in ascending numeric order.
73
77
 
74
78
  - If you provide `#profile-info-1`, `#profile-info-2`, `#profile-info-3` — all three render in order.
75
- - If you provide no `profile-info-*` slots, the component renders `profileInfoCount` empty blocks (they used to contain visible placeholder text, removed 2026-09-27). Slot discovery runs every render, so slots added after mount appear.
76
- - Gaps are supported — you can provide `#profile-info-1` and `#profile-info-3` without `#profile-info-2`; they sort correctly.
79
+ - If you provide none, no info blocks render.
80
+ - Gaps are supported — you can provide `#profile-info-1` and `#profile-info-3` without `#profile-info-2`; `#profile-info-12` sorts after `#profile-info-2`.
81
+ - Discovery runs every render, so slots added or removed after mount (a `v-if` on the slot template) appear and disappear.
77
82
 
78
83
  ## heroText slot prop
79
84
 
@@ -89,10 +94,41 @@ If you use a different heading component, bind `headingId` to whatever prop rend
89
94
 
90
95
  ## Layout
91
96
 
92
- - **Mobile**: single column — picture stacked above info.
93
- - **768px+**: two columns — `--profile-section-picture-width` (`384px`) picture column, `1fr` info column, `--profile-section-gap-wide` (`4rem`) gap (`--profile-section-gap`, `2rem`, below 768px).
94
- - Picture frame (`.profile-section-picture`): `--profile-section-picture-aspect-ratio` (`3 / 4`), `--profile-section-picture-border-radius` (`8px`), `overflow: hidden`. Full token list: `CONSUMER-STYLING.md` in the component folder. The `NuxtImg` fills the frame with `object-fit: cover`.
95
- - Profile links: `align-items: end; justify-content: flex-end` — right-aligned to the bottom of the info column.
97
+ - The root is a size container named `profile-section`. Layout follows **the component's own width**, not the viewport.
98
+ - **Under 768px wide**: single column — picture stacked above info, `--profile-section-gap` (`2rem`).
99
+ - **768px wide or more**: two columns — `minmax(0, var(--profile-section-picture-width))` (`38.4rem`) picture column, `minmax(0, 1fr)` info column, `--profile-section-gap-wide` (`4rem`) gap.
100
+ - Being an inline-size container, the root can't size itself from its content: give it a width in a flex row or other shrink-to-fit context.
101
+ - The root sets `overflow-wrap: anywhere`, so unbroken text and URLs in any slot wrap rather than widening the grid.
102
+ - Picture frame (`.profile-section-picture`): `--profile-section-picture-aspect-ratio` (`3 / 4`), `--profile-section-picture-border-radius` (`0.8rem`), `overflow: hidden`. The `NuxtImg` fills the frame with `object-fit: cover`. Full token list: `CONSUMER-STYLING.md` in the component folder.
103
+ - Profile links: `flex-wrap: wrap; align-items: end; justify-content: flex-end` — right-aligned to the bottom of the info column.
104
+ - To change the picture column width, set `--profile-section-picture-width`; don't override `grid-template-columns` in a viewport `@media` query, which no longer lines up with the component's own switch.
105
+
106
+ ## Header in picture
107
+
108
+ With `header-placement="picture-bottom"` or `"picture-top"`, the `<header>` renders inside `.profile-section-picture`, before the image in the DOM (so it is read first), and sits at the bottom or top of the frame on a scrim that fades away from that edge. It follows ServicesCard's `titles-within-image-wrapper`:
109
+
110
+ - Scrim and text come from `--profile-section-picture-header-*` tokens: scrim colour (`#000`) and opacity (`75%`), a solid stop (`0%`, how far the scrim stays full strength before fading) and a target colour (`transparent`, what it fades into, set it to roughly the photo's colour where the fade ends so it blends instead of leaving a grey band), text colour (`contrast-color()` of the scrim colour where supported, else `#fff`), eyebrow colour, padding (`2.4rem`), gap (`0.4rem`). Full list in `CONSUMER-STYLING.md`.
111
+ - The frame drops `overflow: hidden` in this mode, so its aspect ratio is a minimum: a long header grows the frame rather than being clipped. The image is absolutely positioned and still fills the frame.
112
+ - `aria-labelledby` still points at the heading.
113
+ - Drop spacing utilities like `mb-20` from the heading in this mode; the header's padding handles spacing.
114
+
115
+ ```vue
116
+ <ProfileSection tag="section" header-placement="picture-bottom" :profile-picture="picture">
117
+ <template #eyebrowText>
118
+ <EyebrowText tag="p" font-size="medium" text-content="About Alex" />
119
+ </template>
120
+ <template #heroText="{ headingId }">
121
+ <HeroText :id="headingId" tag="h2" font-size="heading" :text-content="[{ text: 'Alex Morgan', styleClass: 'normal' }]" />
122
+ </template>
123
+ ...
124
+ </ProfileSection>
125
+ ```
126
+
127
+ Two stories show themes built only from tokens: `Header In Picture (Dark, Bottom)`, ServicesCard-style, and `Header In Picture (Light, Top)`. A light scrim needs a dark `--profile-section-picture-header-text-colour`; `contrast-color()` picks one where supported, but set it for older browsers. `--profile-section-info-text-colour` sets the info column's text colour for a themed background.
128
+
129
+ ## Content hooks
130
+
131
+ A `.highlight` inside an element with class `location` gets `--profile-section-highlight-colour` (default `var(--colour-text-accent)`); inside `services` it gets `--profile-section-link-colour` / `--profile-section-link-colour-hover` (defaults `var(--colour-link-default)` / `var(--colour-link-hover)`). Both use `--profile-section-highlight-font-weight` (`600`). The `location`/`services` class can sit on the `<p>` itself or a wrapper.
96
132
 
97
133
  ## CSS notes
98
134
 
@@ -106,8 +142,6 @@ If you use a different heading component, bind `headingId` to whatever prop rend
106
142
  }
107
143
  ```
108
144
 
109
- - Accent-coloured text within `.location` or `.services` info blocks can use the `.highlight` class for `var(--colour-text-accent)` or `var(--colour-link-default)` colouring respectively.
110
-
111
145
  ## Consumer styling scaffold
112
146
 
113
147
  ```vue
@@ -121,6 +155,8 @@ If you use a different heading component, bind `headingId` to whatever prop rend
121
155
  <style>
122
156
  .profile-section {
123
157
  &.my-profile {
158
+ --profile-section-picture-width: 32rem;
159
+
124
160
  .profile-section-header {
125
161
  margin-block-end: 3.2rem;
126
162
  }
@@ -131,6 +167,8 @@ If you use a different heading component, bind `headingId` to whatever prop rend
131
167
 
132
168
  ## Notes
133
169
 
134
- - `NuxtImg` is used for the profile picture — provide a real `src` path. A placeholder (grey box) is shown if the image 404s.
135
- - The component does not expose `imgWidth` / `imgHeight` props for the picture. If IPX optimisation is critical, use a fixed-dimension image and rely on the `384px` column cap as the natural size constraint.
170
+ - `NuxtImg` is used for the profile picture — provide a real `src` path. A broken image shows its alt text inside the 3:4 frame.
171
+ - Pass `width`/`height` in `profilePicture` when your image isn't 3:4 at 828px wide, so IPX/Vercel request the right size.
172
+ - The `With Social Icons` story shows a `SocialIconsList` in `#profileLinks`; size it with `--social-icons-list-icon-size` and `--social-icons-list-gap` on your own wrapper.
173
+ - The `StressTest` story covers hostile content (broken image, unbroken text, RTL, out-of-order slots, many links) at every canvas width.
136
174
  - Component is auto-imported in Nuxt — no import needed.
@@ -57,6 +57,8 @@ import type { ISocialIcon } from "srcdev-nuxt-components";
57
57
  | YouTube | `logos:youtube-icon` | `https://www.youtube.com/@` |
58
58
  | TikTok | `logos:tiktok-icon` | `https://www.tiktok.com/@` |
59
59
 
60
+ `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
+
60
62
  ---
61
63
 
62
64
  ## Usage example
@@ -32,7 +32,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
32
32
  ├── theming-override-default.md — replace the entire default theme with a custom palette (set --theme-hue/--theme-chroma)
33
33
  ├── theming-partial-override.md — override a specific colour token category (palette, buttons, inputs) without a full theme replacement
34
34
  ├── theming-form-geometry-tokens.md — non-colour form/button/input tokens (padding, border-radius, gaps): full inventory, why partial override doesn't need duplicating the source files
35
- ├── theming-component-token-pattern.md — standard shape for overridable component colours: public token + inline fallback, no bare var()/no private-only indirection; the three tiers (theme slots / cross-component families / component-scoped); line-clamp tokens for overflowing text; rollout status
35
+ ├── theming-component-token-pattern.md — standard shape for overridable component colours: public token + inline fallback, no bare var()/no private-only indirection; the three tiers (theme slots / cross-component families / component-scoped); line-clamp tokens for overflowing text; image-overlay scrim tokens; rollout status
36
36
  ├── theming-status-tokens.md — global --status-* colours (success/warning/danger/info/neutral: solid + surface + text), which components read them, how to override once for the whole library
37
37
  ├── theming-dark-mode.md — add a dark scheme in a consumer app (layer is light-only): enable plumbing, full dark token set without light-dark(), light-dark() alternative, light-only pinning
38
38
  ├── colour-scheme-disable.md — disable light/dark scheme support in a consumer app
@@ -108,12 +108,13 @@ Each skill is a single markdown file named `<area>-<task>.md`.
108
108
  ├── 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
109
109
  ├── marquee-scroller.md — MarqueeScroller: infinite logo/badge scroller, per-item dynamic slots (marqueeData id), hover/focus/keyboard pause, reduced-motion, CSS tokens
110
110
  ├── rotating-carousel-image.md — RotatingCarouselImage: 3D rotating image carousel, scroll-parallax tilt, focus/keyboard/hover pause, reduced-motion, CSS tokens
111
- ├── social-icons-list.md — SocialIconsList: data-driven social icon links, ISocialIcon type, logos: icon names, CSS tokens
111
+ ├── 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
112
112
  ├── display-qr-code.md — DisplayQrCode: QR code SVG from a string value, colour/size/variant/radius props, currentColor default
113
113
  ├── capture-qr-code.md — CaptureQrCode: live camera scanner, error state, visibility/route/KeepAlive lifecycle, media stream cleanup
114
114
  ├── decode-qr-code.md — DecodeQrCode: file picker + drag-and-drop image decoder, shared results list, CSS override points
115
115
  ├── auto-grid.md — AutoGrid: auto-fit responsive grid, $slots iteration, --auto-grid-gap/min-col-size-{small,default,large} tokens, is-responsive, semantic tag + aria
116
- ├── display-avatar.md — DisplayAvatar (02.molecules): circular avatar with image/initials fallback (alt announced), size scale tokens, --display-avatar-* colours, chip badge, icon slot
116
+ ├── display-avatar.md — DisplayAvatar (02.molecules): circular avatar with image/initials fallback (alt announced), broken-image fallback, data-size scale tokens, image-size, --display-avatar-* colours, chip badge, icon slot
117
+ ├── profile-section.md — ProfileSection (02.molecules): picture + numbered profile-info-N slots, eyebrow/hero header (above, or over the picture top/bottom on a scrim via header-placement), profileLinks, container-query two-column layout, .location/.services highlight tokens
117
118
  ├── display-theme-switch.md — DisplayThemeSwitch: system/light/dark picker wrapping TripleToggleSwitch, wired to useSettingsStore, labels/icons props, small sizing variant
118
119
  ├── card-core.md — CardCore: generic card container, dynamic named slots as rows, 4 variants, blurred backdrop layer, full CSS token API
119
120
  ├── action-menu.md — ActionMenu + ActionMenuItem: ellipsis trigger + anchored popover menu, indexed item-{n} slots, link/button items, full CSS token API
@@ -268,6 +268,8 @@ export const Default: Story = {
268
268
  Key points:
269
269
 
270
270
  - `computed` is **not** auto-imported in `.ts` story files — import it explicitly from `"vue"`.
271
+ - 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.
272
+ - 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`.
271
273
  - Extra args must be stripped before `v-bind` — spreading unknown keys onto a component makes them unknown HTML attributes.
272
274
  - CSS custom properties set via `:style` on the component root are picked up by `var()` in the component's scoped CSS.
273
275
 
@@ -83,6 +83,54 @@ Prefer this over a prop: line count is presentation, and CSS handles it without
83
83
  layout around it.
84
84
  - Document it in `CONSUMER-STYLING.md` with its default and the `1`/`none` options.
85
85
 
86
+ ## Text over an image: scrim tokens
87
+
88
+ Any component that puts text over a photo (a header in a picture, a card title on its image) gets
89
+ the same scrim token set, so a consumer can match the scrim to their photos. `ProfileSection`'s
90
+ `header-placement="picture-top|picture-bottom"` is the reference; `ServicesCard`'s
91
+ `titles-within-image-wrapper` has the older subset (colour and whole-background override only) and
92
+ should gain the rest when it's next touched.
93
+
94
+ | Token | Default | Purpose |
95
+ |---|---|---|
96
+ | `--{c}-scrim-colour` | `#000` | Colour the scrim starts from at the image's edge |
97
+ | `--{c}-scrim-opacity` | `75%` | Its opacity at the start |
98
+ | `--{c}-scrim-solid-stop` | `0%` | How far it stays full strength before fading, so the text sits on a clean background |
99
+ | `--{c}-scrim-target-colour` | `transparent` | What it fades into: set to roughly the photo's colour where the fade ends, so there's no grey band |
100
+ | `--{c}-scrim` | the gradient | Whole `background` override; `none` removes it |
101
+ | `--{c}-text-colour` | `contrast-color(<scrim colour>)`, `#fff` without support | Text on the scrim |
102
+
103
+ ```css
104
+ .component-header {
105
+ --_scrim-colour: var(--component-scrim-colour, #000);
106
+ --_scrim-start-colour: color-mix(in oklab, var(--_scrim-colour) var(--component-scrim-opacity, 75%), transparent);
107
+ --_scrim-solid-stop: var(--component-scrim-solid-stop, 0%);
108
+ --_scrim-target-colour: var(--component-scrim-target-colour, transparent);
109
+
110
+ color: var(--component-text-colour, #fff);
111
+ background: var(--component-scrim, linear-gradient(to top, rgb(0 0 0 / 0.75), rgb(0 0 0 / 0.75) var(--_scrim-solid-stop), var(--_scrim-target-colour)));
112
+
113
+ @supports (color: color-mix(in oklab, red, transparent)) {
114
+ background: var(--component-scrim, linear-gradient(to top, var(--_scrim-start-colour), var(--_scrim-start-colour) var(--_scrim-solid-stop), var(--_scrim-target-colour)));
115
+ }
116
+
117
+ @supports (color: contrast-color(red)) {
118
+ color: var(--component-text-colour, contrast-color(var(--_scrim-colour)));
119
+ }
120
+ }
121
+ ```
122
+
123
+ - The `@supports` fallback is for Safari before 16.2 (no `color-mix()`): it starts from 75% black
124
+ whatever the scrim colour, but keeps the solid stop and target colour.
125
+ - `contrast-color()` reads the scrim colour, not the photo. Document that a consumer who removes the
126
+ scrim must set the text colour.
127
+ - Round only the corners on the image's edge (`border-end-*-radius: inherit` for a bottom scrim),
128
+ or an opaque target colour shows rounded inner corners.
129
+ - If the text can be long, don't clip it: let the frame grow (absolutely position the image and
130
+ keep `overflow` visible so `aspect-ratio` acts as a minimum).
131
+ - Give the story a light and a dark theme built only from these tokens, each with a photo whose
132
+ edge suits the scrim.
133
+
86
134
  ## Multi-level fallback chains are fine
87
135
 
88
136
  A component that wants to *look like* another component's default (rather than duplicating a
@@ -6,6 +6,13 @@
6
6
  "<DisplayAvatar src=\"${1:/images/profile.jpg}\" alt=\"${2:Jane Smith}\" size=\"${3|md,xs,s,lg,xl|}\" />"
7
7
  ]
8
8
  },
9
+ "SRCDEV DisplayAvatar Large Image": {
10
+ "description": "DisplayAvatar: image avatar resized beyond the scale, with a matching image-size so it stays sharp",
11
+ "scope": "vue,html",
12
+ "body": [
13
+ "<DisplayAvatar src=\"${1:/images/profile.jpg}\" alt=\"${2:Jane Smith}\" :image-size=\"${3:240}\" style=\"--display-avatar-size: ${4:12rem}\" />"
14
+ ]
15
+ },
9
16
  "SRCDEV DisplayAvatar Initials": {
10
17
  "description": "DisplayAvatar: initials derived from alt (alt is announced, initials hidden from screen readers)",
11
18
  "scope": "vue,html",
@@ -5,16 +5,16 @@
5
5
  "body": [
6
6
  "<ProfileSection",
7
7
  " tag=\"section\"",
8
- " :profile-picture=\"{ src: '$1', alt: '$2' }\"",
8
+ " :profile-picture=\"{ src: '$2', alt: '$3' }\"",
9
9
  ">",
10
10
  " <template #eyebrowText>",
11
- " <EyebrowText text-content=\"$3\" />",
11
+ " <EyebrowText text-content=\"$4\" />",
12
12
  " </template>",
13
13
  " <template #heroText=\"{ headingId }\">",
14
- " <HeroText :id=\"headingId\" tag=\"h2\" :text-content=\"[{ text: '$4' }]\" />",
14
+ " <HeroText :id=\"headingId\" tag=\"h2\" :text-content=\"[{ text: '$5' }]\" />",
15
15
  " </template>",
16
16
  " <template #profile-info-1>",
17
- " <p>$5</p>",
17
+ " <p>$6</p>",
18
18
  " </template>",
19
19
  " <template #profile-info-2>",
20
20
  " <p>$6</p>",
@@ -22,6 +22,27 @@
22
22
  "</ProfileSection>"
23
23
  ]
24
24
  },
25
+ "SRCDEV ProfileSection Header In Picture": {
26
+ "description": "ProfileSection with the eyebrow and heading over the bottom or top of the picture, on a scrim",
27
+ "scope": "vue,html",
28
+ "body": [
29
+ "<ProfileSection",
30
+ " tag=\"section\"",
31
+ " header-placement=\"${1|picture-bottom,picture-top|}\"",
32
+ " :profile-picture=\"{ src: '$2', alt: '$3' }\"",
33
+ ">",
34
+ " <template #eyebrowText>",
35
+ " <EyebrowText text-content=\"$4\" />",
36
+ " </template>",
37
+ " <template #heroText=\"{ headingId }\">",
38
+ " <HeroText :id=\"headingId\" tag=\"h2\" :text-content=\"[{ text: '$5' }]\" />",
39
+ " </template>",
40
+ " <template #profile-info-1>",
41
+ " <p>$6</p>",
42
+ " </template>",
43
+ "</ProfileSection>"
44
+ ]
45
+ },
25
46
  "SRCDEV ProfileSection CSS Override": {
26
47
  "description": "CSS override scaffold for ProfileSection tokens",
27
48
  "scope": "css",
@@ -30,6 +51,8 @@
30
51
  " --profile-section-picture-width: $2;",
31
52
  " --profile-section-gap-wide: $3;",
32
53
  " --profile-section-picture-border-radius: $4;",
54
+ " --profile-section-highlight-colour: $5;",
55
+ " --profile-section-link-colour: $6;",
33
56
  "}"
34
57
  ]
35
58
  }
@@ -6,13 +6,17 @@
6
6
 
7
7
  | Token | Default | Controls |
8
8
  |---|---|---|
9
- | `--display-avatar-size` | the size class's value | Diameter, overriding the `size` prop's scale (one instance or a whole area) |
10
- | `--display-avatar-font-size` | the size class's value | Initials/text size, overriding the scale |
9
+ | `--display-avatar-size` | the `size` step's value | Diameter, overriding the `size` prop's scale (one instance or a whole area) |
10
+ | `--display-avatar-font-size` | the `size` step's value | Initials/text size, overriding the scale |
11
11
  | `--display-avatar-size-xs` / `-s` / `-md` / `-lg` / `-xl` | `2.4rem` / `3.2rem` / `4rem` / `4.8rem` / `5.6rem` | The diameter each `size` value maps to |
12
12
  | `--display-avatar-font-size-xs` / `-s` / `-md` / `-lg` / `-xl` | `1.2rem` / `1.4rem` / `1.6rem` / `1.8rem` / `2rem` | The text size each `size` value maps to |
13
13
 
14
- A custom `size` string (anything other than `xs`–`xl`) is added as a class but maps to no scale
15
- step, so it renders at `md` unless you set `--display-avatar-size` for it.
14
+ A custom `size` string (anything other than `xs`–`xl`) is set as `data-size` but maps to no
15
+ scale step, so it renders at `md` unless you set `--display-avatar-size` for it (target it with
16
+ `.display-avatar[data-size="<your-size>"]`).
17
+
18
+ If you size an image avatar beyond the scale, raise the `image-size` prop (default `112`, the
19
+ pixel width and height requested from `@nuxt/image`) to match, or the image is upscaled and blurry.
16
20
 
17
21
  ### Appearance
18
22
 
@@ -30,21 +34,33 @@ step, so it renders at `md` unless you set `--display-avatar-size` for it.
30
34
  > faint in light mode; it now uses the `--theme-*` surface and text tokens, so it follows light
31
35
  > and dark mode.
32
36
 
33
- Private tokens (not public API): `--_size` and `--_font-size`, which each size class sets from its
37
+ Private tokens (not public API): `--_avatar-size` and `--_avatar-font-size`, which each size step sets from its
34
38
  scale token.
35
39
 
36
40
  ## State hooks
37
41
 
38
- - Size class on the root: `.xs`, `.s`, `.md`, `.lg`, `.xl` (or your custom `size` string).
39
- - Classes: `.display-avatar` (root), `.display-avatar-image` (the image), `.display-avatar-icon`
40
- (opt-in, for your `#icon` slot content).
42
+ - `data-size` on the root: `xs`, `s`, `md`, `lg`, `xl` (or your custom `size` string).
43
+ - Classes: `.display-avatar` (root: size, font size, radius), `.display-avatar-surface` (fills the root,
44
+ carries the background and text colour, and holds all content), `.display-avatar-image` (the image), `.display-avatar-fallback`
45
+ (the initials or `text`; one line, clipped to the circle), `.display-avatar-icon` (opt-in, for
46
+ your `#icon` slot content).
41
47
  - With `chip`, the root is `DisplayChip`, so it also carries `.display-chip` and its
42
48
  `data-shape`/`data-status` attributes (status comes from the `status` prop). See `display-chip`
43
49
  for its tokens.
44
50
 
51
+ > **Changed 2026-10-14**: the size was a bare class on the root (`.display-avatar.lg`), which a
52
+ > consumer's own `.lg`/`.xl` utility classes could match. Replace `.display-avatar.<size>` with
53
+ > `.display-avatar[data-size="<size>"]`. Overlong fallback text is now clipped inside the circle, the
54
+ > avatar no longer shrinks in a flex row, and a broken image falls back to the initials.
55
+ > With `chip`, the status dot is its configured size again (it had picked up the avatar's diameter
56
+ > through a shared private `--_size` name). Content now sits in a full-size
57
+ > `.display-avatar-surface`, which carries the background: the chip's cutout used to land on the
58
+ > small initials span (cutting into the letters) and never reached the background. If you set
59
+ > `background` directly on `.display-avatar`, use `--display-avatar-background` instead.
60
+ >
45
61
  > **Changed 2026-10-08**: chip status is the `status` prop (forwarded to `DisplayChip` as
46
62
  > `data-status`), not an `online`/`idle`/`dnd` class through `style-class-passthrough`.
47
-
63
+ >
48
64
  > **Changed 2026-09-27**: `.avatar-image` → `.display-avatar-image` and `.avatar-icon` →
49
65
  > `.display-avatar-icon`.
50
66
 
@@ -56,7 +72,7 @@ Set the tokens above on an element you own (a page or section class, or a class
56
72
  `<style scoped>`, tokens set on your element still work, but selectors that reach inside the
57
73
  component need `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
58
74
 
59
- **Caveat:** the size classes set `--_size`/`--_font-size` on the avatar itself, so changing the
75
+ **Caveat:** the size steps set `--_avatar-size`/`--_avatar-font-size` on the avatar itself, so changing the
60
76
  scale (`--display-avatar-size-md` etc.) from an ancestor works, but a one-off diameter should use
61
77
  `--display-avatar-size`, which beats the scale wherever it's set.
62
78