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
@@ -1,5 +1,11 @@
1
+ import { ref } from "vue";
1
2
  import ProfileSection from "../ProfileSection.vue";
3
+ import EyebrowText from "../../../01.atoms/text-blocks/eyebrow-text/EyebrowText.vue";
4
+ import HeroText from "../../../01.atoms/text-blocks/hero-text/HeroText.vue";
5
+ import SocialIconsList from "../../social-icons-list/SocialIconsList.vue";
6
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
7
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
8
+ import type { ISocialIcon, MediaCanvas } from "~/types/components";
3
9
 
4
10
  const meta: Meta<typeof ProfileSection> = {
5
11
  title: "Molecules/ProfileSection",
@@ -12,11 +18,12 @@ const meta: Meta<typeof ProfileSection> = {
12
18
  },
13
19
  profilePicture: {
14
20
  control: "object",
15
- description: "Profile picture src and alt text",
21
+ description: "Profile picture src, alt text and optional intrinsic width/height",
16
22
  },
17
- profileInfoCount: {
18
- control: { type: "number", min: 1, step: 1 },
19
- description: "Number of profile-info slot blocks to render",
23
+ headerPlacement: {
24
+ control: { type: "inline-radio" },
25
+ options: ["above", "picture-bottom", "picture-top"],
26
+ description: "Where the eyebrow and heading go: above the grid, or over the bottom or top of the picture on a scrim",
20
27
  },
21
28
  styleClassPassthrough: {
22
29
  control: "object",
@@ -27,210 +34,441 @@ const meta: Meta<typeof ProfileSection> = {
27
34
  tag: "div",
28
35
  profilePicture: {
29
36
  src: "/images/services/service-balayage.jpg",
30
- alt: "Natasha — mobile hairdresser in Bath",
37
+ alt: "Alex Morgan, hair stylist",
31
38
  },
32
- profileInfoCount: 3,
33
39
  styleClassPassthrough: [],
34
40
  },
41
+ decorators: [
42
+ (story, context) => ({
43
+ components: { story, CanvasSwitcher },
44
+ setup() {
45
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
46
+ return { canvasName };
47
+ },
48
+ template: `
49
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
50
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
51
+ </div>
52
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
53
+ <story />
54
+ </div>
55
+ `,
56
+ }),
57
+ ],
58
+ parameters: {
59
+ layout: "fullscreen",
60
+ docs: {
61
+ description: {
62
+ component:
63
+ "The layout responds to the component's own width (a container query), not the viewport: it switches to two columns once it is 768px wide. Use the canvas switcher to check each width.",
64
+ },
65
+ },
66
+ },
35
67
  };
36
68
 
37
69
  export default meta;
38
70
  type Story = StoryObj<typeof ProfileSection>;
39
71
 
72
+ const headerSlots = `
73
+ <template #eyebrowText>
74
+ <EyebrowText tag="p" font-size="medium" text-content="About Alex" :style-class-passthrough="['mb-0']" />
75
+ </template>
76
+ <template #heroText="{ headingId }">
77
+ <HeroText
78
+ :id="headingId"
79
+ tag="h2"
80
+ font-size="title"
81
+ :text-content="[
82
+ { text: 'Your', styleClass: 'normal' },
83
+ { text: 'local hair stylist', styleClass: 'accent' },
84
+ { text: 'in Northtown', styleClass: 'normal' },
85
+ ]"
86
+ :style-class-passthrough="['mb-20']"
87
+ />
88
+ </template>
89
+ `;
90
+
91
+ const infoSlots = `
92
+ <template #profile-info-1>
93
+ <p class="page-body-normal">With over 10 years of experience, Alex brings the salon to you, whether you're at home, in the office, or getting ready for a special occasion.</p>
94
+ </template>
95
+ <template #profile-info-2>
96
+ <p class="page-body-normal location">Based in <span class="highlight">Northtown</span>, covering the surrounding area including <span class="highlight">Eastfield</span> and beyond.</p>
97
+ </template>
98
+ <template #profile-info-3>
99
+ <p class="page-body-normal services">Specialising in <a href="#" class="highlight">cuts</a>, <a href="#" class="highlight">colour</a>, and <a href="#" class="highlight">styling</a> for all hair types.</p>
100
+ </template>
101
+ `;
102
+
40
103
  // ─── Stories ─────────────────────────────────────────────────────────────────
41
104
 
42
105
  /** Default — three profile-info blocks with eyebrow and hero text header slots. */
43
106
  export const Default: Story = {
44
107
  render: (args) => ({
45
- components: { ProfileSection },
108
+ components: { ProfileSection, EyebrowText, HeroText, SocialIconsList },
46
109
  setup() {
47
110
  return { args };
48
111
  },
49
112
  template: `
50
113
  <ProfileSection v-bind="args">
51
- <template #eyebrowText>
52
- <EyebrowText tag="p" font-size="large" text-content="About Natasha" :style-class-passthrough="['mb-0']" />
53
- </template>
54
- <template #heroText="{ headingId }">
55
- <HeroText
56
- :id="headingId"
57
- tag="h2"
58
- axis="vertical"
59
- font-size="display"
60
- :text-content="[
61
- { text: 'Your', styleClass: 'normal' },
62
- { text: 'mobile hairdresser', styleClass: 'accent' },
63
- { text: 'in Bath', styleClass: 'normal' },
64
- ]"
65
- :style-class-passthrough="['mb-20']"
66
- />
67
- </template>
68
- <template #profile-info-1>
69
- <div class="experience">
70
- <p class="page-body-normal">With over 10 years of experience as a mobile hairdresser, Natasha brings the salon to you — whether you're at home, in the office, or getting ready for a special occasion.</p>
71
- </div>
72
- </template>
73
- <template #profile-info-2>
74
- <div class="location">
75
- <p class="page-body-normal">Based in <span class="highlight">Bath</span>, covering the surrounding area including Bristol, Frome, and beyond.</p>
76
- </div>
77
- </template>
78
- <template #profile-info-3>
79
- <div class="services">
80
- <p class="page-body-normal">Specialising in <a href="#" class="highlight">cuts</a>, <a href="#" class="highlight">colour</a>, and <a href="#" class="highlight">styling</a> for all hair types.</p>
81
- </div>
82
- </template>
114
+ ${headerSlots}
115
+ ${infoSlots}
83
116
  </ProfileSection>
84
117
  `,
85
118
  }),
119
+ parameters: {
120
+ docs: {
121
+ description: {
122
+ story:
123
+ "The second and third blocks show the content hooks: a `.highlight` inside a `.location` element gets the accent colour, and inside a `.services` element the link colour.",
124
+ },
125
+ },
126
+ },
86
127
  };
87
128
 
88
129
  /** With profile links — demonstrates the optional profileLinks slot. */
89
130
  export const WithProfileLinks: Story = {
90
131
  name: "With Profile Links",
91
132
  render: (args) => ({
92
- components: { ProfileSection },
133
+ components: { ProfileSection, EyebrowText, HeroText, SocialIconsList },
93
134
  setup() {
94
135
  return { args };
95
136
  },
96
137
  template: `
97
138
  <ProfileSection v-bind="args">
139
+ ${headerSlots}
140
+ ${infoSlots}
141
+ <template #profileLinks>
142
+ <a href="#">Instagram</a>
143
+ <a href="#">Book now</a>
144
+ </template>
145
+ </ProfileSection>
146
+ `,
147
+ }),
148
+ };
149
+
150
+ const socialIcons: ISocialIcon[] = [
151
+ { networkName: "Facebook", iconName: "logos:facebook", baseHref: "https://www.facebook.com/", profileId: "yourprofile" },
152
+ { networkName: "Instagram", iconName: "logos:instagram-icon", baseHref: "https://www.instagram.com/", profileId: "yourprofile" },
153
+ { networkName: "TikTok", iconName: "logos:tiktok-icon", baseHref: "https://www.tiktok.com/@", profileId: "yourprofile" },
154
+ ];
155
+
156
+ /** With social icons — a SocialIconsList in the profileLinks slot. */
157
+ export const WithSocialIcons: Story = {
158
+ name: "With Social Icons",
159
+ render: (args) => ({
160
+ components: { ProfileSection, EyebrowText, HeroText, SocialIconsList },
161
+ setup() {
162
+ return { args, socialIcons };
163
+ },
164
+ template: `
165
+ <ProfileSection v-bind="args">
166
+ ${headerSlots}
167
+ ${infoSlots}
168
+ <template #profileLinks>
169
+ <SocialIconsList :items="socialIcons" label="Alex Morgan on social media" />
170
+ </template>
171
+ </ProfileSection>
172
+ `,
173
+ }),
174
+ parameters: {
175
+ docs: {
176
+ description: {
177
+ story:
178
+ "The `profileLinks` slot holding a `SocialIconsList`, right-aligned at the bottom of the info column. Size and space the icons with `--social-icons-list-icon-size` and `--social-icons-list-gap` set on your own wrapper.",
179
+ },
180
+ },
181
+ },
182
+ };
183
+
184
+ // logos:instagram-icon is near-black and vanishes on a dark scheme; mdi:instagram draws in currentColor, so the theme can colour it.
185
+ const themedSocialIcons: ISocialIcon[] = socialIcons.map((icon) =>
186
+ icon.networkName === "Instagram" ? { ...icon, iconName: "mdi:instagram" } : icon
187
+ );
188
+
189
+ // Every value is a token set on a styleClassPassthrough class; the --eyebrow-text-* tokens are EyebrowText's own.
190
+ const pictureThemes = `
191
+ <component is="style">
192
+ .profile-section.dark-overlay-profile {
193
+ --profile-section-picture-border-radius: 0.4rem;
194
+ --profile-section-picture-header-scrim-colour: #141110;
195
+ --profile-section-picture-header-text-colour: #f3ece4;
196
+ --profile-section-picture-header-eyebrow-colour: #c9a24a;
197
+ --profile-section-picture-header-padding: 3.6rem;
198
+ --profile-section-info-text-colour: #a39a94;
199
+ --profile-section-highlight-colour: #c9a24a;
200
+ --profile-section-link-colour: #e8dfd6;
201
+ --profile-section-link-colour-hover: #c9a24a;
202
+ --social-icons-list-icon-colour: #e8dfd6;
203
+ --eyebrow-text-font-family: "Poppins", sans-serif;
204
+ --eyebrow-text-font-style: normal;
205
+ --eyebrow-text-font-weight: 600;
206
+ --eyebrow-text-letter-spacing: 0.25em;
207
+ }
208
+
209
+ .profile-section.light-overlay-profile {
210
+ --profile-section-picture-border-radius: 1.6rem;
211
+ --profile-section-picture-header-scrim-colour: #f7f2ec;
212
+ --profile-section-picture-header-scrim-opacity: 90%;
213
+ --profile-section-picture-header-scrim-solid-stop: 45%;
214
+ --profile-section-picture-header-scrim-target-colour: rgb(247 242 236 / 0);
215
+ --profile-section-picture-header-text-colour: #2b2420;
216
+ --profile-section-picture-header-eyebrow-colour: #9a6b3f;
217
+ --profile-section-picture-header-padding: 3.2rem 3.2rem 6.4rem;
218
+ --profile-section-info-text-colour: #5a514b;
219
+ --profile-section-highlight-colour: #9a6b3f;
220
+ --profile-section-link-colour: #2b2420;
221
+ --profile-section-link-colour-hover: #9a6b3f;
222
+ --social-icons-list-icon-colour: #2b2420;
223
+ --eyebrow-text-font-family: "Poppins", sans-serif;
224
+ --eyebrow-text-font-style: normal;
225
+ --eyebrow-text-font-weight: 600;
226
+ --eyebrow-text-letter-spacing: 0.25em;
227
+ }
228
+ </component>
229
+ `;
230
+
231
+ const pictureHeaderRender = (themeClass: string, background: string): Story["render"] => (args) => ({
232
+ components: { ProfileSection, EyebrowText, HeroText, SocialIconsList },
233
+ setup() {
234
+ return { args, themedSocialIcons, themeClass };
235
+ },
236
+ template: `
237
+ ${pictureThemes}
238
+ <div style="padding: 2.4rem; background: ${background};">
239
+ <ProfileSection v-bind="args" :style-class-passthrough="[themeClass]">
98
240
  <template #eyebrowText>
99
- <EyebrowText tag="p" font-size="large" text-content="About Natasha" :style-class-passthrough="['mb-0']" />
241
+ <EyebrowText tag="p" font-size="medium" text-content="About Alex" :style-class-passthrough="['mb-0']" />
100
242
  </template>
101
243
  <template #heroText="{ headingId }">
102
- <HeroText
103
- :id="headingId"
104
- tag="h2"
105
- axis="vertical"
106
- font-size="display"
107
- :text-content="[
108
- { text: 'Your', styleClass: 'normal' },
109
- { text: 'mobile hairdresser', styleClass: 'accent' },
110
- { text: 'in Bath', styleClass: 'normal' },
111
- ]"
112
- :style-class-passthrough="['mb-20']"
113
- />
244
+ <HeroText :id="headingId" tag="h2" font-size="heading" :text-content="[{ text: 'Alex Morgan', styleClass: 'normal' }]" />
114
245
  </template>
246
+ ${infoSlots}
247
+ <template #profileLinks>
248
+ <SocialIconsList :items="themedSocialIcons" label="Alex Morgan on social media" />
249
+ </template>
250
+ </ProfileSection>
251
+ </div>
252
+ `,
253
+ });
254
+
255
+ /** Header in picture, dark — eyebrow and heading at the bottom of the image, on a dark scrim. */
256
+ export const HeaderInPictureDark: Story = {
257
+ name: "Header In Picture (Dark, Bottom)",
258
+ args: {
259
+ tag: "section",
260
+ headerPlacement: "picture-bottom",
261
+ },
262
+ render: pictureHeaderRender("dark-overlay-profile", "#121010"),
263
+ parameters: {
264
+ docs: {
265
+ description: {
266
+ story:
267
+ "With `header-placement=\"picture-bottom\"`, the eyebrow and heading sit at the bottom of the picture on a scrim, like ServicesCard's `titles-within-image-wrapper`. A long header grows the frame rather than being clipped. " +
268
+ "Every value is a token set on a class added with `styleClassPassthrough`: `--profile-section-picture-header-*` for the scrim and header, `--profile-section-info-text-colour` and the highlight/link tokens for the info column, and EyebrowText's own `--eyebrow-text-*` tokens. Instagram uses `mdi:instagram` coloured with `--social-icons-list-icon-colour`, since `logos:instagram-icon` is near-black and vanishes here. Switch `headerPlacement` in Controls to move the header to the top.",
269
+ },
270
+ },
271
+ },
272
+ };
273
+
274
+ /** Header in picture, light — eyebrow and heading at the top of the image, on a light scrim. */
275
+ export const HeaderInPictureLight: Story = {
276
+ name: "Header In Picture (Light, Top)",
277
+ args: {
278
+ tag: "section",
279
+ headerPlacement: "picture-top",
280
+ profilePicture: {
281
+ src: "/images/services/lowlights-1.webp",
282
+ alt: "Alex Morgan, hair stylist",
283
+ width: 828,
284
+ height: 958,
285
+ },
286
+ },
287
+ render: pictureHeaderRender("light-overlay-profile", "#fbf8f4"),
288
+ parameters: {
289
+ docs: {
290
+ description: {
291
+ story:
292
+ "With `header-placement=\"picture-top\"`, the header sits at the top of the picture and the scrim fades downward. A light scrim colour needs a dark `--profile-section-picture-header-text-colour`; browsers with `contrast-color()` pick one automatically, but setting it keeps older browsers right too. The photo's top edge is a cream ceiling beam, so the cream scrim reads as part of the image: it stays solid for the first 45% (behind the text), then fades into `--profile-section-picture-header-scrim-target-colour`, a fully transparent version of the same cream, so it thins out into the pale background without greying. Over a darker photo, set the target to roughly the photo's colour where the fade ends. The extra end padding gives the fade room below the text.",
293
+ },
294
+ },
295
+ },
296
+ };
297
+
298
+ /** With block headings — each profile-info block has its own h3 title. */
299
+ export const WithBlockHeadings: Story = {
300
+ name: "With Block Headings",
301
+ args: {
302
+ tag: "section",
303
+ },
304
+ render: (args) => ({
305
+ components: { ProfileSection, EyebrowText, HeroText, SocialIconsList },
306
+ setup() {
307
+ return { args, socialIcons };
308
+ },
309
+ template: `
310
+ <component is="style">
311
+ .profile-section.block-headings-profile {
312
+ --profile-section-info-block-gap: 3.2rem;
313
+
314
+ .about-block-heading {
315
+ margin-block: 0 0.8rem;
316
+ }
317
+
318
+ .profile-section-info-block p {
319
+ margin: 0;
320
+ }
321
+ }
322
+ </component>
323
+ <ProfileSection v-bind="args" :style-class-passthrough="['block-headings-profile']">
324
+ ${headerSlots}
115
325
  <template #profile-info-1>
116
- <div class="experience">
117
- <p class="page-body-normal">With over 10 years of experience as a mobile hairdresser, Natasha brings the salon to you.</p>
118
- </div>
326
+ <h3 class="page-heading-4 about-block-heading">Experience</h3>
327
+ <p class="page-body-normal">With over 10 years of experience, Alex brings the salon to you, whether you're at home, in the office, or getting ready for a special occasion.</p>
119
328
  </template>
120
329
  <template #profile-info-2>
121
- <div class="location">
122
- <p class="page-body-normal">Based in <span class="highlight">Bath</span>, covering the surrounding area including Bristol, Frome, and beyond.</p>
123
- </div>
330
+ <h3 class="page-heading-4 about-block-heading">Where Alex works</h3>
331
+ <p class="page-body-normal location">Based in <span class="highlight">Northtown</span>, covering the surrounding area including <span class="highlight">Eastfield</span> and beyond.</p>
124
332
  </template>
125
333
  <template #profile-info-3>
126
- <div class="services">
127
- <p class="page-body-normal">Specialising in <a href="#" class="highlight">cuts</a>, <a href="#" class="highlight">colour</a>, and <a href="#" class="highlight">styling</a> for all hair types.</p>
128
- </div>
334
+ <h3 class="page-heading-4 about-block-heading">Services</h3>
335
+ <p class="page-body-normal services">Specialising in <a href="#" class="highlight">cuts</a>, <a href="#" class="highlight">colour</a>, and <a href="#" class="highlight">styling</a> for all hair types.</p>
129
336
  </template>
130
337
  <template #profileLinks>
131
- <a href="#">Instagram</a>
132
- <a href="#">Book now</a>
338
+ <SocialIconsList :items="socialIcons" label="Alex Morgan on social media" />
133
339
  </template>
134
340
  </ProfileSection>
135
341
  `,
136
342
  }),
343
+ parameters: {
344
+ docs: {
345
+ description: {
346
+ story:
347
+ "Each `profile-info-N` slot is free-form, so a block can carry its own heading. Use `h3` under the section's `h2` to keep the heading order. The headings use the `page-heading-4` typography class, and the spacing is set in a consumer stylesheet on a class added with `styleClassPassthrough`, plus `--profile-section-info-block-gap` between blocks.",
348
+ },
349
+ },
350
+ },
137
351
  };
138
352
 
139
- /** No header slots — renders without eyebrow or hero text. */
353
+ /** No header slots — the header element is not rendered at all. */
140
354
  export const WithoutHeader: Story = {
141
355
  name: "Without Header Slots",
142
356
  render: (args) => ({
143
- components: { ProfileSection },
357
+ components: { ProfileSection, EyebrowText, HeroText, SocialIconsList },
144
358
  setup() {
145
359
  return { args };
146
360
  },
147
361
  template: `
148
362
  <ProfileSection v-bind="args">
149
- <template #profile-info-1>
150
- <p class="page-body-normal">Profile information block one.</p>
151
- </template>
152
- <template #profile-info-2>
153
- <p class="page-body-normal">Profile information block two.</p>
154
- </template>
155
- <template #profile-info-3>
156
- <p class="page-body-normal">Profile information block three.</p>
157
- </template>
363
+ ${infoSlots}
158
364
  </ProfileSection>
159
365
  `,
160
366
  }),
161
367
  };
162
368
 
163
- /** Section tag — renders the root as a semantic section element. */
369
+ /** Section tag — renders the root as a labelled section landmark. */
164
370
  export const AsSectionTag: Story = {
165
371
  name: "As section Tag",
166
372
  args: {
167
373
  tag: "section",
168
374
  },
169
375
  render: (args) => ({
170
- components: { ProfileSection },
376
+ components: { ProfileSection, EyebrowText, HeroText, SocialIconsList },
171
377
  setup() {
172
378
  return { args };
173
379
  },
174
380
  template: `
175
381
  <ProfileSection v-bind="args">
176
- <template #eyebrowText>
177
- <EyebrowText tag="p" font-size="large" text-content="About Natasha" :style-class-passthrough="['mb-0']" />
178
- </template>
179
- <template #heroText="{ headingId }">
180
- <HeroText
181
- :id="headingId"
182
- tag="h2"
183
- axis="horizontal"
184
- font-size="large"
185
- :text-content="[{ text: 'Profile section heading', styleClass: 'normal' }]"
186
- />
187
- </template>
188
- <template #profile-info-1>
189
- <p class="page-body-normal">Profile information block one.</p>
190
- </template>
191
- <template #profile-info-2>
192
- <p class="page-body-normal">Profile information block two.</p>
193
- </template>
194
- <template #profile-info-3>
195
- <p class="page-body-normal">Profile information block three.</p>
196
- </template>
382
+ ${headerSlots}
383
+ ${infoSlots}
197
384
  </ProfileSection>
198
385
  `,
199
386
  }),
200
387
  };
201
388
 
202
- /** Custom count — renders a different number of profile-info slot blocks. */
203
- export const CustomInfoCount: Story = {
204
- name: "Custom Profile Info Count",
389
+ /** Narrow canvas — the same section stacked into one column. */
390
+ export const NarrowContainer: Story = {
391
+ name: "Narrow Container",
392
+ render: Default.render,
393
+ parameters: {
394
+ initialCanvas: "mobileCanvas",
395
+ docs: {
396
+ description: {
397
+ story: "At mobile width the picture stacks above the info column.",
398
+ },
399
+ },
400
+ },
401
+ };
402
+
403
+ const unbroken = "Pneumonoultramicroscopicsilicovolcanoconiosis".repeat(3);
404
+ const longGerman =
405
+ "Mit über zehn Jahren Berufserfahrung als mobile Friseurmeisterin bringt Alex den Salon direkt zu Ihnen nach Hause, ins Büro oder zur Hochzeitsvorbereitung, Donaudampfschifffahrtsgesellschaftskapitänsmütze inklusive.";
406
+
407
+ export const StressTest: Story = {
408
+ name: "Stress Test (Worst-Case Data)",
205
409
  args: {
206
- profileInfoCount: 2,
410
+ tag: "section",
411
+ profilePicture: {
412
+ src: "/images/does-not-exist.jpg",
413
+ alt: "<b>Broken image</b> with HTML-like alt text that is also rather long",
414
+ width: -1,
415
+ height: 0,
416
+ },
207
417
  },
208
418
  render: (args) => ({
209
- components: { ProfileSection },
419
+ components: { ProfileSection, EyebrowText, HeroText, SocialIconsList },
210
420
  setup() {
211
- return { args };
421
+ return { args, unbroken, longGerman };
212
422
  },
213
423
  template: `
214
424
  <ProfileSection v-bind="args">
215
425
  <template #eyebrowText>
216
- <EyebrowText tag="p" font-size="large" text-content="About Natasha" :style-class-passthrough="['mb-0']" />
426
+ <EyebrowText tag="p" font-size="large" :text-content="unbroken" :style-class-passthrough="['mb-0']" />
217
427
  </template>
218
428
  <template #heroText="{ headingId }">
219
- <HeroText
220
- :id="headingId"
221
- tag="h2"
222
- axis="horizontal"
223
- font-size="large"
224
- :text-content="[{ text: 'Profile section heading', styleClass: 'normal' }]"
225
- />
429
+ <HeroText :id="headingId" tag="h2" font-size="title" :text-content="[{ text: longGerman, styleClass: 'normal' }]" />
226
430
  </template>
227
- <template #profile-info-1>
228
- <p class="page-body-normal">First block of information about this person.</p>
431
+ <template #profile-info-12>
432
+ <p class="page-body-normal">Slot 12 (should render last, after 2)</p>
229
433
  </template>
230
434
  <template #profile-info-2>
231
- <p class="page-body-normal">Second block of information about this person.</p>
435
+ <p class="page-body-normal location">Covering <span class="highlight">{{ unbroken }}</span> and https://example.com/a/very/long/url/without/any/spaces/that/keeps/going/and/going</p>
436
+ </template>
437
+ <template #profile-info-1>
438
+ <p class="page-body-normal">{{ longGerman }} {{ longGerman }}</p>
439
+ <p class="page-body-normal" dir="rtl">مرحبا بكم في صالوننا، نحن نقدم جميع خدمات الشعر 🦄💇‍♀️</p>
440
+ <p class="page-body-normal">&lt;script&gt;alert(1)&lt;/script&gt; renders as text</p>
441
+ </template>
442
+ <template #profileLinks>
443
+ <a v-for="n in 12" :key="n" href="#">Link number {{ n }}</a>
444
+ <a href="#">{{ unbroken }}</a>
445
+ </template>
446
+ </ProfileSection>
447
+ <ProfileSection v-bind="args" header-placement="picture-bottom" :profile-picture="{ src: '/images/services/service-colour.jpg', alt: 'Header within picture' }" style="margin-block-start: 4.8rem;">
448
+ <template #eyebrowText>
449
+ <EyebrowText tag="p" font-size="medium" :text-content="unbroken" :style-class-passthrough="['mb-0']" />
450
+ </template>
451
+ <template #heroText="{ headingId }">
452
+ <HeroText :id="headingId" tag="h2" font-size="title" :text-content="[{ text: longGerman + ' ' + longGerman, styleClass: 'normal' }]" />
453
+ </template>
454
+ <template #profile-info-1>
455
+ <p class="page-body-normal">The header above is long enough to overflow the 3:4 frame: the frame should grow to fit it, not clip it.</p>
232
456
  </template>
233
457
  </ProfileSection>
234
458
  `,
235
459
  }),
460
+ parameters: {
461
+ // Fixed showcase: hardcoded values, so Controls would do nothing here.
462
+ controls: { disable: true },
463
+ docs: {
464
+ description: {
465
+ story:
466
+ "Hostile content: a broken image with an invalid width and height and a long HTML-like alt, an unbroken eyebrow, a long German heading, " +
467
+ "info slots given out of order with a gap (1, 2, 12), unbroken words and a long URL inside a `.location .highlight`, right-to-left text with emoji, " +
468
+ "and a dozen profile links plus an unbroken one. Check at every canvas width that nothing overflows the dashed outline, the picture column " +
469
+ "never pushes the info column off-screen, the info blocks render in numeric order, the links wrap, and the broken image keeps its 3:4 frame. " +
470
+ "The second section puts an oversized header within the picture: the frame should grow taller than 3:4 to fit it, with nothing clipped.",
471
+ },
472
+ },
473
+ },
236
474
  };