srcdev-nuxt-components 9.6.1 → 9.6.3

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 (66) hide show
  1. package/.claude/rules/consumer/consumer-overrides.md +17 -0
  2. package/.claude/rules/consumer/css-comment-slash.md +13 -0
  3. package/.claude/rules/consumer/css-native-nesting.md +13 -0
  4. package/.claude/rules/consumer/css-no-deep.md +13 -0
  5. package/.claude/rules/consumer/css-rem-base.md +11 -0
  6. package/.claude/rules/consumer/css-surface-text-pairing.md +13 -0
  7. package/.claude/rules/consumer/css-webkit-support.md +15 -0
  8. package/.claude/rules/consumer/explicit-vue-imports.md +11 -0
  9. package/.claude/rules/consumer/nuxt-img-dimensions.md +12 -0
  10. package/.claude/rules/consumer/srcdev-skills-location.md +9 -0
  11. package/.claude/rules/consumer/story-args.md +12 -0
  12. package/.claude/rules/consumer/story-register-components.md +12 -0
  13. package/.claude/rules/consumer/vue-auto-imports.md +14 -0
  14. package/.claude/rules/consumer/vue-no-self-closing.md +11 -0
  15. package/.claude/rules/consumer/vue-prop-hyphenation.md +11 -0
  16. package/.claude/rules/consumer/vue-props-pattern.md +13 -0
  17. package/.claude/rules/consumer/vue-slots-no-computed.md +14 -0
  18. package/.claude/rules/consumer/vue-video-source.md +12 -0
  19. package/.claude/skills/component-compliance-checklist.md +37 -2
  20. package/.claude/skills/component-naming.md +1 -1
  21. package/.claude/skills/components/display-avatar.md +14 -3
  22. package/.claude/skills/components/display-chip.md +2 -1
  23. package/.claude/skills/components/google-reviews.md +2 -2
  24. package/.claude/skills/components/pricing-card.md +68 -84
  25. package/.claude/skills/components/profile-section.md +59 -21
  26. package/.claude/skills/components/social-icons-list.md +2 -0
  27. package/.claude/skills/index.md +13 -6
  28. package/.claude/skills/new-app-scaffold.md +4 -2
  29. package/.claude/skills/setup-postinstall.md +5 -4
  30. package/.claude/skills/storybook-add-story.md +38 -0
  31. package/.claude/skills/theming-component-token-pattern.md +94 -0
  32. package/.claude/skills/theming-partial-override.md +1 -1
  33. package/.vscode/srcdev-component-display-avatar.code-snippets +7 -0
  34. package/.vscode/srcdev-component-pricing-card.code-snippets +46 -84
  35. package/.vscode/srcdev-component-profile-section.code-snippets +27 -4
  36. package/README.md +5 -5
  37. package/app/components/01.atoms/animations/scroll-reveal-frame/stories/ScrollRevealFrame.stories.ts +2 -0
  38. package/app/components/01.atoms/animations/scroll-reveal-image/stories/ScrollRevealImage.stories.ts +2 -0
  39. package/app/components/01.atoms/animations/section-parallax/stories/SectionParallax.stories.ts +4 -0
  40. package/app/components/01.atoms/animations/view-timeline/stories/WipeAwayVertical.stories.ts +2 -0
  41. package/app/components/02.molecules/cookie-consent-banner/stories/CookieConsentBanner.stories.ts +2 -0
  42. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +26 -10
  43. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +75 -34
  44. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +84 -3
  45. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +85 -13
  46. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +3 -3
  47. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +7 -2
  48. package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +0 -1
  49. package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +96 -149
  50. package/app/components/02.molecules/pricing-card/PricingCard.vue +84 -44
  51. package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +241 -104
  52. package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +149 -123
  53. package/app/components/02.molecules/profile-section/CONSUMER-STYLING.md +103 -13
  54. package/app/components/02.molecules/profile-section/ProfileSection.vue +123 -23
  55. package/app/components/02.molecules/profile-section/stories/ProfileSection.stories.ts +353 -115
  56. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +144 -11
  57. package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap +6 -11
  58. package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +6 -2
  59. package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +1 -0
  60. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +4 -0
  61. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +4 -0
  62. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +1 -1
  63. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +6 -6
  64. package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabel.vue +1 -1
  65. package/app/composables/useAriaDescribedById.ts +1 -1
  66. package/package.json +2 -1
@@ -3,16 +3,27 @@
3
3
  :is="chip ? DisplayChip : as"
4
4
  v-bind="chip ? { tag: chipTag, config: chipConfig, status, statusLabel } : {}"
5
5
  class="display-avatar"
6
- :class="[size, elementClasses]"
6
+ :class="elementClasses"
7
+ :data-size="size"
7
8
  >
8
- <slot name="default">
9
- <NuxtImg v-if="src" :src :alt="alt ?? ''" width="100%" height="100%" class="display-avatar-image" />
10
- <template v-else>
11
- <span :aria-hidden="alt ? 'true' : undefined">{{ fallback }}</span>
12
- <span v-if="alt" class="sr-only">{{ alt }}</span>
13
- </template>
14
- </slot>
15
- <slot name="icon"></slot>
9
+ <span class="display-avatar-surface">
10
+ <slot name="default">
11
+ <NuxtImg
12
+ v-if="src && !imageFailed"
13
+ :src
14
+ :alt="alt ?? ''"
15
+ :width="imageSize"
16
+ :height="imageSize"
17
+ class="display-avatar-image"
18
+ @error="imageFailed = true"
19
+ />
20
+ <template v-else>
21
+ <span class="display-avatar-fallback" :aria-hidden="alt ? 'true' : undefined">{{ fallback }}</span>
22
+ <span v-if="alt" class="sr-only">{{ alt }}</span>
23
+ </template>
24
+ </slot>
25
+ <slot name="icon"></slot>
26
+ </span>
16
27
  </component>
17
28
  </template>
18
29
 
@@ -29,6 +40,7 @@ interface Props {
29
40
  chip?: boolean | DisplayChipConfig;
30
41
  status?: DisplayChipStatus;
31
42
  statusLabel?: string;
43
+ imageSize?: number;
32
44
  styleClassPassthrough?: string | string[];
33
45
  }
34
46
 
@@ -41,6 +53,7 @@ const props = withDefaults(defineProps<Props>(), {
41
53
  chip: undefined,
42
54
  status: undefined,
43
55
  statusLabel: undefined,
56
+ imageSize: 112,
44
57
  styleClassPassthrough: () => [],
45
58
  });
46
59
 
@@ -51,14 +64,26 @@ watch(
51
64
  () => resetElementClasses(props.styleClassPassthrough)
52
65
  );
53
66
 
67
+ const imageFailed = ref(false);
68
+
69
+ watch(
70
+ () => props.src,
71
+ () => (imageFailed.value = false)
72
+ );
73
+
74
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
75
+ const firstGrapheme = (word: string) => graphemes.segment(word)[Symbol.iterator]().next().value?.segment ?? "";
76
+
54
77
  const fallback = computed(
55
78
  () =>
56
- props.text ||
79
+ props.text?.trim() ||
57
80
  (props.alt || "")
58
- .split(" ")
59
- .map((word) => word.charAt(0))
81
+ .trim()
82
+ .split(/\s+/)
83
+ .filter(Boolean)
84
+ .slice(0, 2)
85
+ .map(firstGrapheme)
60
86
  .join("")
61
- .substring(0, 2)
62
87
  );
63
88
 
64
89
  const chipDefaultConfig: DisplayChipConfig = {
@@ -76,36 +101,52 @@ const chipTag = computed((): "div" | "span" => (props.as === "div" || props.as =
76
101
  <style lang="css">
77
102
  @layer components {
78
103
  .display-avatar {
79
- --_size: var(--display-avatar-size-md, 4rem);
80
- --_font-size: var(--display-avatar-font-size-md, 1.6rem);
104
+ --_avatar-size: var(--display-avatar-size-md, 4rem);
105
+ --_avatar-font-size: var(--display-avatar-font-size-md, 1.6rem);
81
106
 
82
107
  display: flex;
83
- align-items: center;
84
- justify-content: center;
85
- width: var(--display-avatar-size, var(--_size));
86
- height: var(--display-avatar-size, var(--_size));
87
- font-size: var(--display-avatar-font-size, var(--_font-size));
108
+ flex-shrink: 0;
109
+ width: var(--display-avatar-size, var(--_avatar-size));
110
+ height: var(--display-avatar-size, var(--_avatar-size));
111
+ font-size: var(--display-avatar-font-size, var(--_avatar-font-size));
88
112
  border-radius: var(--display-avatar-border-radius, 50%);
89
- background-color: var(--display-avatar-background, var(--theme-surface-subtle));
90
- color: var(--display-avatar-text-colour, var(--theme-text));
91
113
 
92
114
  isolation: isolate;
93
115
 
94
- &.xs {
95
- --_size: var(--display-avatar-size-xs, 2.4rem);
96
- --_font-size: var(--display-avatar-font-size-xs, 1.2rem);
116
+ /* Fills the root so DisplayChip's per-child cutout mask is sized to the whole avatar. */
117
+ .display-avatar-surface {
118
+ display: flex;
119
+ align-items: center;
120
+ justify-content: center;
121
+ width: 100%;
122
+ height: 100%;
123
+ border-radius: inherit;
124
+ background-color: var(--display-avatar-background, var(--theme-surface-subtle));
125
+ color: var(--display-avatar-text-colour, var(--theme-text));
126
+ }
127
+
128
+ &[data-size="xs"] {
129
+ --_avatar-size: var(--display-avatar-size-xs, 2.4rem);
130
+ --_avatar-font-size: var(--display-avatar-font-size-xs, 1.2rem);
131
+ }
132
+ &[data-size="s"] {
133
+ --_avatar-size: var(--display-avatar-size-s, 3.2rem);
134
+ --_avatar-font-size: var(--display-avatar-font-size-s, 1.4rem);
97
135
  }
98
- &.s {
99
- --_size: var(--display-avatar-size-s, 3.2rem);
100
- --_font-size: var(--display-avatar-font-size-s, 1.4rem);
136
+ &[data-size="lg"] {
137
+ --_avatar-size: var(--display-avatar-size-lg, 4.8rem);
138
+ --_avatar-font-size: var(--display-avatar-font-size-lg, 1.8rem);
101
139
  }
102
- &.lg {
103
- --_size: var(--display-avatar-size-lg, 4.8rem);
104
- --_font-size: var(--display-avatar-font-size-lg, 1.8rem);
140
+ &[data-size="xl"] {
141
+ --_avatar-size: var(--display-avatar-size-xl, 5.6rem);
142
+ --_avatar-font-size: var(--display-avatar-font-size-xl, 2rem);
105
143
  }
106
- &.xl {
107
- --_size: var(--display-avatar-size-xl, 5.6rem);
108
- --_font-size: var(--display-avatar-font-size-xl, 2rem);
144
+
145
+ .display-avatar-fallback {
146
+ min-inline-size: 0;
147
+ max-inline-size: 100%;
148
+ overflow: hidden;
149
+ white-space: nowrap;
109
150
  }
110
151
 
111
152
  .display-avatar-image {
@@ -98,6 +98,16 @@ export default {
98
98
  },
99
99
  } as Meta<typeof StorybookComponent>;
100
100
 
101
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
102
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
103
+ const googleReviewsNote = `
104
+ <p style="${storyNoteStyle}">
105
+ GoogleReviewCard puts an avatar beside each reviewer's name, with their photo or initials and its own avatar colour tokens. See the
106
+ <a href="/?path=/story/organisms-google-reviews--default" target="_top">Google Reviews</a> story, and
107
+ <a href="/?path=/story/organisms-google-reviews--no-avatars" target="_top">No Avatars</a> for the initials fallback.
108
+ </p>
109
+ `;
110
+
101
111
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
112
  const useChip = (args: any) =>
103
113
  computed(() => ({
@@ -113,7 +123,8 @@ const Template: StoryFn<typeof StorybookComponent> = (args) => ({
113
123
  return { args, chip: useChip(args) };
114
124
  },
115
125
  template: `
116
- <div style="display: flex; align-items: center; justify-content: center; height: 100vh;">
126
+ ${googleReviewsNote}
127
+ <div style="display: flex; align-items: center; justify-content: center; min-height: 80vh;">
117
128
  <StorybookComponent
118
129
  :size="args.size"
119
130
  :src="args.src"
@@ -135,7 +146,8 @@ export const Initials: StoryFn<typeof StorybookComponent> = (args) => ({
135
146
  return { args, chip: useChip(args) };
136
147
  },
137
148
  template: `
138
- <div style="display: flex; align-items: center; justify-content: center; gap: 1.6rem; height: 100vh;">
149
+ ${googleReviewsNote}
150
+ <div style="display: flex; align-items: center; justify-content: center; gap: 1.6rem; min-height: 80vh;">
139
151
  <StorybookComponent :size="args.size" :alt="args.alt" :chip="chip" :status="args.status" :status-label="args.statusLabel" :style-class-passthrough="args.styleClassPassthrough" />
140
152
  <StorybookComponent :size="args.size" text="?" :chip="chip" :status="args.status" :status-label="args.statusLabel" :style-class-passthrough="args.styleClassPassthrough" />
141
153
  </div>
@@ -150,7 +162,8 @@ export const Sizes: StoryFn<typeof StorybookComponent> = (args) => ({
150
162
  return { args, chip: useChip(args), sizes: ["xs", "s", "md", "lg", "xl"] };
151
163
  },
152
164
  template: `
153
- <div style="display: flex; align-items: center; justify-content: center; gap: 1.6rem; height: 100vh;">
165
+ ${googleReviewsNote}
166
+ <div style="display: flex; align-items: center; justify-content: center; gap: 1.6rem; min-height: 80vh;">
154
167
  <StorybookComponent
155
168
  v-for="size in sizes"
156
169
  :key="size"
@@ -167,3 +180,71 @@ export const Sizes: StoryFn<typeof StorybookComponent> = (args) => ({
167
180
  });
168
181
  Sizes.args = { src: undefined, alt: "Jane Smith" };
169
182
  Sizes.argTypes = { size: { table: { disable: true } } };
183
+
184
+ const stressAvatars = [
185
+ { key: "long-text", caption: "Long text prop", props: { text: "Administrator account" } },
186
+ { key: "unbroken", caption: "Unbroken alt", props: { alt: "Pneumonoultramicroscopicsilicovolcanoconiosis" } },
187
+ { key: "german", caption: "Long German name", props: { alt: "Maximilian-Alexander von Hohenzollern-Sigmaringen" } },
188
+ { key: "emoji", caption: "Emoji first", props: { alt: "🦄 Unicorn Team" } },
189
+ { key: "zwj", caption: "ZWJ family emoji", props: { alt: "👩‍👩‍👧 Family" } },
190
+ { key: "flags", caption: "Flag emoji", props: { alt: "🇬🇧 🇫🇷" } },
191
+ { key: "whitespace", caption: "Extra whitespace", props: { alt: " Jane Smith " } },
192
+ { key: "text-blank", caption: "Whitespace text prop", props: { alt: "Jane Smith", text: " " } },
193
+ { key: "single", caption: "Single character", props: { alt: "J" } },
194
+ { key: "empty", caption: "No alt, text or src", props: {} },
195
+ { key: "html", caption: "HTML-like alt", props: { alt: "<b>Bold</b> <script>" } },
196
+ { key: "rtl", caption: "Right-to-left", props: { alt: "محمد علي" }, dir: "rtl" },
197
+ { key: "wide", caption: "Widest glyphs", props: { text: "WW" } },
198
+ { key: "broken", caption: "Broken src", props: { src: "/does-not-exist.jpg", alt: "Jane Smith" } },
199
+ { key: "broken-no-alt", caption: "Broken src, no alt", props: { src: "/does-not-exist.jpg" } },
200
+ { key: "custom-size", caption: "Unknown size string", props: { alt: "Jane Smith", size: "huge" } },
201
+ ];
202
+
203
+ export const StressTest: StoryFn<typeof StorybookComponent> = () => ({
204
+ components: { StorybookComponent },
205
+ setup() {
206
+ return { stressAvatars, sizes: ["xs", "s", "md", "lg", "xl"] };
207
+ },
208
+ template: `
209
+ ${googleReviewsNote}
210
+ <div style="display: grid; gap: 4.8rem; padding: 4rem 2rem; font: 1.2rem sans-serif;">
211
+ <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 3.2rem 2.4rem;">
212
+ <figure
213
+ v-for="avatar in stressAvatars"
214
+ :key="avatar.key"
215
+ :dir="avatar.dir"
216
+ style="margin: 0; display: grid; justify-items: center; align-content: start; gap: 1.2rem; min-inline-size: 0;"
217
+ >
218
+ <StorybookComponent v-bind="avatar.props" :chip="true" status="online" status-label="Online" />
219
+ <figcaption style="text-align: center; overflow-wrap: anywhere;">{{ avatar.caption }}</figcaption>
220
+ </figure>
221
+ </div>
222
+
223
+ <div style="display: flex; align-items: center; gap: 1.6rem;">
224
+ <StorybookComponent v-for="size in sizes" :key="size" :size="size" text="WW" />
225
+ </div>
226
+
227
+ <div style="display: grid; gap: 1.2rem; inline-size: 20rem; border: 1px dashed currentColor; padding: 0.8rem;">
228
+ <div v-for="size in sizes" :key="size" style="display: flex; align-items: center; gap: 0.8rem;">
229
+ <StorybookComponent :size="size" alt="Jane Smith" />
230
+ <span style="min-inline-size: 0; overflow-wrap: anywhere;">Averyveryverylongunbrokenusername@example.com</span>
231
+ </div>
232
+ </div>
233
+ </div>
234
+ `,
235
+ });
236
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
237
+ StressTest.parameters = {
238
+ // Fixed showcase: hardcoded values, so Controls would do nothing here.
239
+ controls: { disable: true },
240
+ docs: {
241
+ description: {
242
+ story:
243
+ "Hostile names and sources: long and unbroken text, emoji (including ZWJ and flags) at the start of a name, extra whitespace, a whitespace-only " +
244
+ "text prop, a single character, nothing at all, HTML-like and right-to-left text, the widest Latin glyphs at every size, broken image URLs with and " +
245
+ "without alt, and an unknown size string. The last block puts each size in a narrow flex row next to a long unbroken name. " +
246
+ "Check that initials are whole characters (never a broken half emoji), overlong text is clipped inside the circle rather than spilling out, " +
247
+ "a broken image falls back to initials (or an empty circle with no alt), an unknown size renders at md, and no avatar squashes into an oval in the narrow rows.",
248
+ },
249
+ },
250
+ };
@@ -64,14 +64,15 @@ describe("DisplayAvatar", () => {
64
64
 
65
65
  // ─── Size ─────────────────────────────────────────────────────────────────
66
66
 
67
- it("applies md size class by default", async () => {
67
+ it("sets data-size=\"md\" by default", async () => {
68
68
  const wrapper = await mountSuspended(DisplayAvatar);
69
- expect(wrapper.classes()).toContain("md");
69
+ expect(wrapper.attributes("data-size")).toBe("md");
70
70
  });
71
71
 
72
- it.each(["xs", "s", "md", "lg", "xl"] as const)("applies %s size class when size='%s'", async (size) => {
72
+ it.each(["xs", "s", "md", "lg", "xl"] as const)("sets data-size=\"%s\" when size='%s'", async (size) => {
73
73
  const wrapper = await mountSuspended(DisplayAvatar, { props: { size } });
74
- expect(wrapper.classes()).toContain(size);
74
+ expect(wrapper.attributes("data-size")).toBe(size);
75
+ expect(wrapper.classes()).not.toContain(size);
75
76
  });
76
77
 
77
78
  // ─── Fallback text ────────────────────────────────────────────────────────
@@ -80,33 +81,33 @@ describe("DisplayAvatar", () => {
80
81
  const wrapper = await mountSuspended(DisplayAvatar, {
81
82
  props: { alt: "John Doe" },
82
83
  });
83
- expect(wrapper.find(".display-avatar > span:first-child").text()).toBe("JD");
84
+ expect(wrapper.find(".display-avatar-fallback").text()).toBe("JD");
84
85
  });
85
86
 
86
87
  it("caps initials at two characters", async () => {
87
88
  const wrapper = await mountSuspended(DisplayAvatar, {
88
89
  props: { alt: "Alice Bob Charlie" },
89
90
  });
90
- expect(wrapper.find(".display-avatar > span:first-child").text()).toBe("AB");
91
+ expect(wrapper.find(".display-avatar-fallback").text()).toBe("AB");
91
92
  });
92
93
 
93
94
  it("shows single initial for a single-word alt", async () => {
94
95
  const wrapper = await mountSuspended(DisplayAvatar, {
95
96
  props: { alt: "Alice" },
96
97
  });
97
- expect(wrapper.find(".display-avatar > span:first-child").text()).toBe("A");
98
+ expect(wrapper.find(".display-avatar-fallback").text()).toBe("A");
98
99
  });
99
100
 
100
101
  it("shows text prop instead of alt-derived initials when text is set", async () => {
101
102
  const wrapper = await mountSuspended(DisplayAvatar, {
102
103
  props: { alt: "John Doe", text: "?" },
103
104
  });
104
- expect(wrapper.find(".display-avatar > span:first-child").text()).toBe("?");
105
+ expect(wrapper.find(".display-avatar-fallback").text()).toBe("?");
105
106
  });
106
107
 
107
108
  it("shows empty fallback when neither src, text, nor alt are set", async () => {
108
109
  const wrapper = await mountSuspended(DisplayAvatar);
109
- expect(wrapper.find(".display-avatar > span:first-child").text()).toBe("");
110
+ expect(wrapper.find(".display-avatar-fallback").text()).toBe("");
110
111
  });
111
112
 
112
113
  // ─── Image ────────────────────────────────────────────────────────────────
@@ -210,13 +211,13 @@ describe("DisplayAvatar", () => {
210
211
 
211
212
  it("hides the initials from screen readers and announces the alt instead", async () => {
212
213
  const wrapper = await mountSuspended(DisplayAvatar, { props: { alt: "John Doe" } });
213
- expect(wrapper.find(".display-avatar > span:first-child").attributes("aria-hidden")).toBe("true");
214
+ expect(wrapper.find(".display-avatar-fallback").attributes("aria-hidden")).toBe("true");
214
215
  expect(wrapper.find(".sr-only").text()).toBe("John Doe");
215
216
  });
216
217
 
217
218
  it("leaves text-only fallback readable when there is no alt", async () => {
218
219
  const wrapper = await mountSuspended(DisplayAvatar, { props: { text: "?" } });
219
- expect(wrapper.find(".display-avatar > span:first-child").attributes("aria-hidden")).toBeUndefined();
220
+ expect(wrapper.find(".display-avatar-fallback").attributes("aria-hidden")).toBeUndefined();
220
221
  expect(wrapper.find(".sr-only").exists()).toBe(false);
221
222
  });
222
223
 
@@ -225,11 +226,12 @@ describe("DisplayAvatar", () => {
225
226
  expect(wrapper.attributes("style-class-passthrough")).toBeUndefined();
226
227
  });
227
228
 
228
- it("puts display-avatar, size and passthrough classes on the DisplayChip root", async () => {
229
+ it("puts display-avatar, data-size and passthrough classes on the DisplayChip root", async () => {
229
230
  const wrapper = await mountSuspended(DisplayAvatar, {
230
231
  props: { chip: true, size: "lg", styleClassPassthrough: ["online"] },
231
232
  });
232
- expect(wrapper.classes()).toEqual(expect.arrayContaining(["display-chip", "display-avatar", "lg", "online"]));
233
+ expect(wrapper.classes()).toEqual(expect.arrayContaining(["display-chip", "display-avatar", "online"]));
234
+ expect(wrapper.attributes("data-size")).toBe("lg");
233
235
  });
234
236
 
235
237
  it("forwards status and statusLabel to the chip", async () => {
@@ -256,4 +258,74 @@ describe("DisplayAvatar", () => {
256
258
  await wrapper.setProps({ chip: { size: "20px", maskWidth: "2px", offset: "0px", angle: "0deg" } });
257
259
  expect(wrapper.findComponent(DisplayChip).props("config")).toMatchObject({ size: "20px" });
258
260
  });
261
+
262
+ // ─── Stress test 2026-10-14 ────────────────────────────────────────────────
263
+
264
+ const initialsFor = async (alt: string) => {
265
+ const wrapper = await mountSuspended(DisplayAvatar, { props: { alt } });
266
+ return wrapper.find(".display-avatar-fallback").text();
267
+ };
268
+
269
+ it("takes whole emoji as initials, not half a surrogate pair", async () => {
270
+ expect(await initialsFor("🦄 Unicorn")).toBe("🦄U");
271
+ expect(await initialsFor("👩‍👩‍👧 Family")).toBe("👩‍👩‍👧F");
272
+ expect(await initialsFor("🇬🇧 Team")).toBe("🇬🇧T");
273
+ });
274
+
275
+ it("ignores leading, trailing and repeated whitespace in alt", async () => {
276
+ expect(await initialsFor(" Jane Smith ")).toBe("JS");
277
+ expect(await initialsFor("Jane\tSmith")).toBe("JS");
278
+ });
279
+
280
+ it("falls back to alt initials when text is whitespace only", async () => {
281
+ const wrapper = await mountSuspended(DisplayAvatar, { props: { alt: "Jane Smith", text: " " } });
282
+ expect(wrapper.find(".display-avatar-fallback").text()).toBe("JS");
283
+ });
284
+
285
+ it("renders HTML-like alt as text", async () => {
286
+ const wrapper = await mountSuspended(DisplayAvatar, { props: { alt: "<b>Bold</b> <script>" } });
287
+ expect(wrapper.find("b").exists()).toBe(false);
288
+ expect(wrapper.find(".sr-only").text()).toBe("<b>Bold</b> <script>");
289
+ });
290
+
291
+ it("passes a numeric width and height to the image", async () => {
292
+ const wrapper = await mountSuspended(DisplayAvatar, { props: { src: "/avatar.jpg" } });
293
+ const img = wrapper.find(".display-avatar-image");
294
+ expect(img.attributes("width")).toBe("112");
295
+ expect(img.attributes("height")).toBe("112");
296
+ });
297
+
298
+ it("uses imageSize for the image's width and height", async () => {
299
+ const wrapper = await mountSuspended(DisplayAvatar, { props: { src: "/avatar.jpg", imageSize: 240 } });
300
+ expect(wrapper.find(".display-avatar-image").attributes("width")).toBe("240");
301
+ });
302
+
303
+ it("shows the initials fallback when the image fails to load", async () => {
304
+ const wrapper = await mountSuspended(DisplayAvatar, { props: { src: "/broken.jpg", alt: "Jane Smith" } });
305
+ await wrapper.find(".display-avatar-image").trigger("error");
306
+ expect(wrapper.find(".display-avatar-image").exists()).toBe(false);
307
+ expect(wrapper.find(".display-avatar-fallback").text()).toBe("JS");
308
+ expect(wrapper.find(".sr-only").text()).toBe("Jane Smith");
309
+ });
310
+
311
+ it("tries the image again when src changes after a failure", async () => {
312
+ const wrapper = await mountSuspended(DisplayAvatar, { props: { src: "/broken.jpg", alt: "Jane Smith" } });
313
+ await wrapper.find(".display-avatar-image").trigger("error");
314
+ await wrapper.setProps({ src: "/working.jpg" });
315
+ expect(wrapper.find(".display-avatar-image").exists()).toBe(true);
316
+ });
317
+
318
+ it("wraps all content in one surface so the chip's per-child mask covers the whole avatar", async () => {
319
+ const wrapper = await mountSuspended(DisplayAvatar, {
320
+ props: { alt: "Jane Smith", chip: true },
321
+ slots: { icon: "<span class='status-icon'>●</span>" },
322
+ });
323
+ const masked = Array.from((wrapper.element as HTMLElement).children).filter(
324
+ (el) => !el.matches(".display-chip-icon, .display-chip-label, .sr-only")
325
+ );
326
+ expect(masked).toHaveLength(1);
327
+ expect(masked[0]!.classList.contains("display-avatar-surface")).toBe(true);
328
+ expect(masked[0]!.querySelector(".display-avatar-fallback")?.textContent).toBe("JS");
329
+ expect(masked[0]!.querySelector(".status-icon")).not.toBeNull();
330
+ });
259
331
  });
@@ -1,12 +1,12 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
- exports[`DisplayAvatar > renders correct HTML structure (default) 1`] = `"<span class="display-avatar md"><span aria-hidden="true">JD</span><span class="sr-only">John Doe</span></span>"`;
3
+ exports[`DisplayAvatar > renders correct HTML structure (default) 1`] = `"<span class="display-avatar" data-size="md"><span class="display-avatar-surface"><span class="display-avatar-fallback" aria-hidden="true">JD</span><span class="sr-only">John Doe</span></span></span>"`;
4
4
 
5
5
  exports[`DisplayAvatar > renders correct HTML structure (with chip) 1`] = `
6
- "<span class="display-chip display-avatar md" data-shape="circle" data-status="offline" style="--_chip-size: 12px; --_chip-mask-width: 4px; --_chip-offset: 0px; --_chip-angle: 90deg;"><span aria-hidden="true">JD</span><span class="sr-only">John Doe</span>
6
+ "<span class="display-chip display-avatar" data-shape="circle" data-status="offline" style="--_chip-size: 12px; --_chip-mask-width: 4px; --_chip-offset: 0px; --_chip-angle: 90deg;" data-size="md"><span class="display-avatar-surface"><span class="display-avatar-fallback" aria-hidden="true">JD</span><span class="sr-only">John Doe</span></span>
7
7
  <!--v-if-->
8
8
  <!--v-if-->
9
9
  <!--v-if--></span>"
10
10
  `;
11
11
 
12
- exports[`DisplayAvatar > renders correct HTML structure (with src) 1`] = `"<span class="display-avatar lg"><img data-nuxt-img="" srcset="/_ipx/_/avatar.jpg 1x, /_ipx/_/avatar.jpg 2x" alt="John Doe" class="display-avatar-image" src="/_ipx/_/avatar.jpg"></span>"`;
12
+ exports[`DisplayAvatar > renders correct HTML structure (with src) 1`] = `"<span class="display-avatar" data-size="lg"><span class="display-avatar-surface"><img width="112" height="112" data-nuxt-img="" srcset="/_ipx/s_112x112/avatar.jpg 1x, /_ipx/s_224x224/avatar.jpg 2x" alt="John Doe" class="display-avatar-image" src="/_ipx/s_112x112/avatar.jpg"></span></span>"`;
@@ -58,7 +58,10 @@ maths (`--_mask-diameter`, `--_position-x`, `--_position-y`, `--_offset`, `--_ci
58
58
 
59
59
  Inner elements: the dot is `.display-chip::after`; `.display-chip-icon` and `.display-chip-label`
60
60
  sit on top of it. The `statusLabel` text is a `.sr-only` span. Every other direct child gets the
61
- cutout `mask-image`.
61
+ cutout `mask-image`. The mask is positioned in percentages of each child's own box, so the slot
62
+ should be one element that fills the chip and carries any background: a small child (a bare text
63
+ span) gets the notch cut into its own content, and a background on the root is never masked.
64
+ `DisplayAvatar` uses a full-size `.display-avatar-surface` for this.
62
65
 
63
66
  > Changed 2026-10-08: shape and status are `data-shape`/`data-status` attributes instead of the
64
67
  > `.circle`/`.square` and `.online`/`.idle`/`.dnd` classes, so they can't collide with a
@@ -87,4 +90,6 @@ let the token apply.
87
90
  ## Class passthrough
88
91
 
89
92
  `style-class-passthrough` adds classes to the root `.display-chip`. Reactive after mount.
90
- `DisplayAvatar` with `chip` renders this component as its root, so the same applies there.
93
+ `DisplayAvatar` with `chip` renders this component as its root, so the same applies there. Because the
94
+ root element is shared, a wrapping component must not reuse this component's private token names
95
+ (`--_size` especially) for its own values.
@@ -4,7 +4,6 @@ import InputCopy from "../InputCopy.vue";
4
4
  const meta: Meta<typeof InputCopy> = {
5
5
  title: "Molecules/Input Copy",
6
6
  component: InputCopy,
7
- tags: ["autodocs"],
8
7
  argTypes: {
9
8
  value: { control: "text" },
10
9
  id: {