srcdev-nuxt-components 9.6.5 → 9.6.6

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 (26) hide show
  1. package/.claude/rules/consumer/a11y-no-focus-steal.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +5 -1
  3. package/.claude/skills/components/display-toast.md +6 -3
  4. package/.claude/skills/components/social-icons-list.md +32 -5
  5. package/.claude/skills/css-dynamic-border-radius.md +114 -0
  6. package/.claude/skills/index.md +2 -1
  7. package/.claude/skills/storybook-add-story.md +6 -0
  8. package/.vscode/srcdev-component-display-toast.code-snippets +6 -8
  9. package/.vscode/srcdev-component-social-icons-list.code-snippets +14 -0
  10. package/.vscode/srcdev-utility-dynamic-border-radius.code-snippets +31 -0
  11. package/app/assets/styles/setup/06.utility-classes/_dynamic-border-radius.css +27 -0
  12. package/app/assets/styles/setup/06.utility-classes/index.css +1 -0
  13. package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +97 -8
  14. package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +80 -14
  15. package/app/components/02.molecules/social-icons-list/stories/SocialIconsList.stories.ts +230 -2
  16. package/app/components/02.molecules/social-icons-list/stories/social-icons-list-recipes.css +108 -0
  17. package/app/components/02.molecules/social-icons-list/tests/SocialIconsList.spec.ts +123 -52
  18. package/app/components/02.molecules/social-icons-list/tests/__snapshots__/SocialIconsList.spec.ts.snap +10 -4
  19. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +9 -3
  20. package/app/components/02.molecules/toast/DisplayToast.vue +10 -10
  21. package/app/components/02.molecules/toast/DisplayToastProvider.vue +12 -4
  22. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +200 -2
  23. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +33 -12
  24. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +20 -0
  25. package/app/types/components/social-icons-list.d.ts +9 -0
  26. package/package.json +1 -1
@@ -1,35 +1,64 @@
1
1
  <template>
2
- <ul class="social-icons-list" :class="[elementClasses]" :aria-label="label">
3
- <li v-for="item in items" :key="item.networkName" class="social-icon-item">
2
+ <ul class="social-icons-list" :class="[elementClasses]" role="list" :aria-label="label">
3
+ <li v-for="entry in entries" :key="entry.key" class="social-icons-list-item" :style="brandStyle(entry.item)">
4
4
  <a
5
- :href="`${item.baseHref}${item.profileId}`"
6
- class="social-icon-link"
7
- :aria-label="linkLabel(item)"
5
+ :href="entry.href"
6
+ class="social-icons-list-link"
7
+ :aria-label="linkLabel(entry.item)"
8
8
  rel="noopener noreferrer"
9
9
  target="_blank"
10
10
  >
11
- <Icon :name="item.iconName" class="social-icon" aria-hidden="true" />
11
+ <span class="social-icons-list-icon-wrapper" aria-hidden="true">
12
+ <slot name="icon" :item="entry.item">
13
+ <Icon :name="entry.item.iconName" class="social-icons-list-icon" />
14
+ </slot>
15
+ </span>
16
+ <span v-if="hasLabel()" class="social-icons-list-label">
17
+ <slot name="label" :item="entry.item">{{ entry.item.networkName }}</slot>
18
+ </span>
12
19
  </a>
13
20
  </li>
14
21
  </ul>
15
22
  </template>
16
23
 
17
24
  <script setup lang="ts">
18
- import type { ISocialIcon } from "~/types/components/social-icons-list.d";
25
+ import type { ISocialIcon, SocialIconsListSlots } from "~/types/components/social-icons-list.d";
19
26
 
20
27
  interface Props {
21
28
  items: ISocialIcon[];
22
29
  label?: string;
23
30
  linkLabelTemplate?: string;
31
+ showLabel?: boolean;
24
32
  styleClassPassthrough?: string | string[];
25
33
  }
26
34
 
27
35
  const props = withDefaults(defineProps<Props>(), {
28
36
  label: "Social media profiles",
29
37
  linkLabelTemplate: "{network} profile (opens in a new tab)",
38
+ showLabel: false,
30
39
  styleClassPassthrough: () => [],
31
40
  });
32
41
 
42
+ const slots = defineSlots<SocialIconsListSlots>();
43
+
44
+ const SAFE_HREF = /^(https?:|mailto:|tel:|\/(?!\/))/i;
45
+
46
+ const entries = computed(() =>
47
+ props.items.flatMap((item, index) => {
48
+ const href = `${item.baseHref}${item.profileId}`.trim();
49
+ if (!SAFE_HREF.test(href)) {
50
+ if (import.meta.dev) console.warn(`[SocialIconsList] Skipped "${item.networkName}": unsafe href "${href}".`);
51
+ return [];
52
+ }
53
+ return [{ item, href, key: `${index}-${item.networkName}` }];
54
+ })
55
+ );
56
+
57
+ const hasLabel = () => props.showLabel || !!slots.label;
58
+
59
+ const brandStyle = (item: ISocialIcon) =>
60
+ item.brandColour ? { "--social-icons-list-brand-colour": item.brandColour } : undefined;
61
+
33
62
  const linkLabel = (item: ISocialIcon) => item.label ?? props.linkLabelTemplate.replace("{network}", item.networkName);
34
63
 
35
64
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
@@ -52,37 +81,74 @@ watch(
52
81
  padding: 0;
53
82
  margin: 0;
54
83
 
55
- .social-icon-item {
84
+ .social-icons-list-item {
56
85
  display: flex;
86
+ min-inline-size: 0;
57
87
 
58
- .social-icon-link {
88
+ .social-icons-list-link {
59
89
  display: flex;
60
90
  align-items: center;
61
- justify-content: center;
91
+ gap: var(--social-icons-list-link-gap, 0.8rem);
92
+ min-inline-size: 0;
93
+ padding: var(--social-icons-list-link-padding, 0);
94
+ border: var(--social-icons-list-link-border, none);
95
+ border-radius: var(--social-icons-list-link-radius, 0);
96
+ background: var(--social-icons-list-link-background, transparent);
97
+ box-shadow: var(--social-icons-list-link-shadow, none);
98
+ color: var(--social-icons-list-label-colour, currentColor);
62
99
  text-decoration: none;
63
100
  transition:
64
101
  transform 200ms ease,
65
- opacity 200ms ease;
102
+ opacity 200ms ease,
103
+ background-color 200ms ease,
104
+ box-shadow 200ms ease;
66
105
 
67
106
  &:hover,
68
107
  &:focus-visible {
69
108
  transform: scale(var(--social-icons-list-hover-scale, 1.15));
70
109
  opacity: var(--social-icons-list-hover-opacity, 0.85);
110
+ background: var(--social-icons-list-link-background-hover, var(--social-icons-list-link-background, transparent));
111
+ box-shadow: var(--social-icons-list-link-shadow-hover, var(--social-icons-list-link-shadow, none));
71
112
 
72
113
  @media (prefers-reduced-motion: reduce) {
73
114
  transform: none;
74
115
  }
116
+
117
+ .social-icons-list-icon {
118
+ color: var(--social-icons-list-icon-colour-hover, var(--social-icons-list-icon-colour, currentColor));
119
+ }
75
120
  }
76
121
 
77
122
  &:focus-visible {
78
- outline: 2px solid var(--theme-ring, currentColor);
123
+ outline: 2px solid var(--social-icons-list-focus-ring-colour, var(--theme-ring, currentColor));
79
124
  outline-offset: 3px;
80
- border-radius: 2px;
81
125
  }
82
126
 
83
- .social-icon {
127
+ .social-icons-list-icon-wrapper {
128
+ display: flex;
129
+ flex-shrink: 0;
130
+ align-items: center;
131
+ justify-content: center;
132
+ box-sizing: content-box;
133
+ inline-size: 1em;
134
+ block-size: 1em;
135
+ padding: var(--social-icons-list-icon-wrapper-padding, 0);
136
+ border: var(--social-icons-list-icon-wrapper-border, none);
137
+ border-radius: var(--social-icons-list-icon-wrapper-radius, 0);
138
+ background: var(--social-icons-list-icon-wrapper-background, transparent);
84
139
  font-size: var(--social-icons-list-icon-size, 2.4rem);
140
+ }
141
+
142
+ .social-icons-list-icon {
85
143
  color: var(--social-icons-list-icon-colour, currentColor);
144
+ transition: color 200ms ease;
145
+ }
146
+
147
+ .social-icons-list-label {
148
+ min-inline-size: 0;
149
+ overflow-wrap: anywhere;
150
+ font-size: var(--social-icons-list-label-font-size, var(--step-4, 1.6rem));
151
+ font-weight: var(--social-icons-list-label-font-weight, 600);
86
152
  }
87
153
  }
88
154
  }
@@ -1,6 +1,10 @@
1
+ import { ref } from "vue";
1
2
  import SocialIconsList from "../SocialIconsList.vue";
3
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
5
  import type { ISocialIcon } from "~/types/components/social-icons-list.d";
6
+ import type { MediaCanvas } from "~/types/components";
7
+ import "./social-icons-list-recipes.css";
4
8
 
5
9
  const sampleItems: ISocialIcon[] = [
6
10
  {
@@ -42,7 +46,7 @@ const meta: Meta<typeof SocialIconsList> = {
42
46
  items: {
43
47
  control: "object",
44
48
  description:
45
- "Array of social network items. Each item requires networkName, iconName (Iconify logos: collection), baseHref, and profileId.",
49
+ "Array of social network items. Each item needs networkName, iconName (any Iconify icon), baseHref and profileId; the link is baseHref + profileId. Items whose href isn't http(s), mailto, tel or site-relative are skipped.",
46
50
  },
47
51
  label: {
48
52
  control: "text",
@@ -52,6 +56,10 @@ const meta: Meta<typeof SocialIconsList> = {
52
56
  control: "text",
53
57
  description: "aria-label for each link; {network} is replaced with the item's networkName",
54
58
  },
59
+ showLabel: {
60
+ control: "boolean",
61
+ description: "Show each item's networkName as visible text after the icon (or fill the `label` slot)",
62
+ },
55
63
  styleClassPassthrough: {
56
64
  control: "object",
57
65
  description: "Additional CSS classes applied to the root element",
@@ -61,8 +69,18 @@ const meta: Meta<typeof SocialIconsList> = {
61
69
  items: sampleItems,
62
70
  label: "Social media profiles",
63
71
  linkLabelTemplate: "{network} profile (opens in a new tab)",
72
+ showLabel: false,
64
73
  styleClassPassthrough: ["m-40"],
65
74
  },
75
+ parameters: {
76
+ docs: {
77
+ description: {
78
+ component:
79
+ "A wrapping row of icon links to social profiles, built from the `items` array (`ISocialIcon`: `networkName`, `iconName`, `baseHref`, `profileId`, optional `label`). " +
80
+ "Links open in a new tab and are named from `linkLabelTemplate` or each item's `label`; `label` names the list; `showLabel` (or the scoped `label` slot) adds visible text, and the scoped `icon` slot replaces the icon. An item's optional `brandColour` is exposed as `--social-icons-list-brand-colour` for recipes. Size, tiles, hover and focus come from `--social-icons-list-*` tokens; the Recipe stories show four alternative looks built only from tokens and consumer CSS.",
81
+ },
82
+ },
83
+ },
66
84
  };
67
85
 
68
86
  export default meta;
@@ -91,6 +109,9 @@ export const LargeIcons: Story = {
91
109
  },
92
110
  template: `<SocialIconsList v-bind="args" style="--social-icons-list-icon-size: 4rem;" />`,
93
111
  }),
112
+ parameters: {
113
+ docs: { description: { story: "`--social-icons-list-icon-size: 4rem` set on the component." } },
114
+ },
94
115
  };
95
116
 
96
117
  /** Single network — only one item in the list. */
@@ -102,7 +123,7 @@ export const SingleNetwork: Story = {
102
123
  networkName: "Instagram",
103
124
  iconName: "logos:instagram-icon",
104
125
  baseHref: "https://www.instagram.com/",
105
- profileId: "luxurylocs",
126
+ profileId: "yourprofile",
106
127
  },
107
128
  ],
108
129
  },
@@ -114,3 +135,210 @@ export const SingleNetwork: Story = {
114
135
  template: `<SocialIconsList v-bind="args" />`,
115
136
  }),
116
137
  };
138
+
139
+ const stressItems: ISocialIcon[] = [
140
+ ...sampleItems,
141
+ { networkName: "Instagram", iconName: "logos:instagram-icon", baseHref: "https://www.instagram.com/", profileId: "second-account" },
142
+ {
143
+ networkName: "LinkedIn",
144
+ iconName: "logos:linkedin-icon",
145
+ baseHref: "https://www.linkedin.com/in/",
146
+ profileId: "a-very-long-profile-identifier-without-any-spaces-at-all-in-it-1234567890",
147
+ },
148
+ { networkName: "Pinterest", iconName: "logos:pinterest", baseHref: "https://www.pinterest.com/", profileId: "" },
149
+ { networkName: "Unknown icon", iconName: "logos:this-icon-does-not-exist", baseHref: "https://example.com/", profileId: "x" },
150
+ { networkName: "<script>alert('xss')</script>", iconName: "mdi:web", baseHref: "https://example.com/", profileId: "html" },
151
+ { networkName: "إنستغرام", iconName: "mdi:instagram", baseHref: "https://www.instagram.com/", profileId: "rtl", label: "ملفنا على إنستغرام" },
152
+ { networkName: "Unsafe", iconName: "mdi:alert", baseHref: "javascript:", profileId: "alert(1)" },
153
+ { networkName: "Email", iconName: "mdi:email", baseHref: "mailto:", profileId: "hello@example.com" },
154
+ { networkName: "Phone", iconName: "mdi:phone", baseHref: "tel:", profileId: "+441234567890" },
155
+ ...Array.from({ length: 10 }, (_, i) => ({
156
+ networkName: `Network ${i + 1}`,
157
+ iconName: "mdi:web",
158
+ baseHref: "https://example.com/",
159
+ profileId: `profile-${i + 1}`,
160
+ })),
161
+ ];
162
+
163
+ /**
164
+ * Stress test — duplicate networks, unbroken and empty profile IDs, a missing icon, HTML-like and
165
+ * right-to-left names, an unsafe href and twenty-plus items, inside the CanvasSwitcher.
166
+ */
167
+ export const StressTest: Story = {
168
+ name: "Stress Test (Worst-Case Data)",
169
+ args: {
170
+ items: stressItems,
171
+ label: "Datenschutzfreundliche Profile in sozialen Netzwerken unseres Unternehmens",
172
+ linkLabelTemplate: "Unser {network}-Profil (öffnet sich in einem neuen Tab)",
173
+ styleClassPassthrough: [],
174
+ },
175
+ decorators: [
176
+ (story, context) => ({
177
+ components: { story, CanvasSwitcher },
178
+ setup() {
179
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
180
+ return { canvasName };
181
+ },
182
+ template: `
183
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
184
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
185
+ </div>
186
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
187
+ <story />
188
+ </div>
189
+ `,
190
+ }),
191
+ ],
192
+ render: (args) => ({
193
+ components: { SocialIconsList },
194
+ setup() {
195
+ return { args };
196
+ },
197
+ template: `<SocialIconsList v-bind="args" />`,
198
+ }),
199
+ parameters: {
200
+ initialCanvas: "mobileCanvas",
201
+ docs: {
202
+ description: {
203
+ story:
204
+ "Check at every canvas width: the row wraps and never overflows the dashed box. Both Instagram accounts render (they used to share a `:key`). " +
205
+ "The `javascript:` item is skipped, so there's no 'Unsafe' link; mailto and tel links stay. The unknown icon still leaves a 1em focus target; tab through to check the focus ring on every link. " +
206
+ "Inspect the links for the German template, the Arabic `label` override, and the HTML-like name rendered as plain text in its `aria-label`.",
207
+ },
208
+ },
209
+ },
210
+ };
211
+
212
+ // ─── Recipes ─────────────────────────────────────────────────────────────────
213
+ // Each look is tokens plus consumer CSS on a passthrough class: see social-icons-list-recipes.css
214
+ // and the Recipes section of CONSUMER-STYLING.md.
215
+
216
+ const brandItems: ISocialIcon[] = [
217
+ { networkName: "Facebook", iconName: "bi:facebook", baseHref: "https://www.facebook.com/", profileId: "yourprofile", brandColour: "#1877f2" },
218
+ { networkName: "X", iconName: "bi:twitter-x", baseHref: "https://x.com/", profileId: "yourhandle", brandColour: "#000000" },
219
+ { networkName: "Instagram", iconName: "bi:instagram", baseHref: "https://www.instagram.com/", profileId: "yourprofile", brandColour: "#e1306c" },
220
+ { networkName: "YouTube", iconName: "bi:youtube", baseHref: "https://www.youtube.com/@", profileId: "yourchannel", brandColour: "#ff0000" },
221
+ { networkName: "TikTok", iconName: "bi:tiktok", baseHref: "https://www.tiktok.com/@", profileId: "yourprofile", brandColour: "#000000" },
222
+ ];
223
+
224
+ // Black brand colours vanish on a dark dock, so X and TikTok glow in their light/accent colours there.
225
+ const darkBrandItems: ISocialIcon[] = brandItems.map((item) =>
226
+ item.networkName === "X"
227
+ ? { ...item, brandColour: "#ffffff" }
228
+ : item.networkName === "TikTok"
229
+ ? { ...item, brandColour: "#25f4ee" }
230
+ : item
231
+ );
232
+
233
+ const recipeStory = (panelStyle: string, before = ""): Story["render"] => (args) => ({
234
+ components: { SocialIconsList },
235
+ setup() {
236
+ return { args, panelStyle };
237
+ },
238
+ template: `
239
+ <div :style="'display: flex; align-items: center; justify-content: center; gap: 1.6rem; flex-wrap: wrap; min-block-size: 24rem; padding: 4rem 2.4rem; border-radius: 2.4rem; font-family: sans-serif; ' + panelStyle">
240
+ ${before}
241
+ <SocialIconsList v-bind="args" />
242
+ </div>
243
+ `,
244
+ });
245
+
246
+ /** Mono icons on soft tiles; each icon takes its item's brandColour on hover or keyboard focus. */
247
+ export const RecipeMonoTiles: Story = {
248
+ name: "Recipe: Mono Tiles",
249
+ args: { items: brandItems, styleClassPassthrough: ["demo-social-mono-tiles"] },
250
+ render: recipeStory("background: #ffffff;"),
251
+ parameters: {
252
+ docs: {
253
+ description: {
254
+ story:
255
+ "Mono `bi:` icons, tile tokens (`--social-icons-list-link-padding`, `-radius`, `-background`, `-background-hover`) and " +
256
+ "`--social-icons-list-icon-colour-hover: var(--social-icons-list-brand-colour)` set on `.social-icons-list-item`.",
257
+ },
258
+ },
259
+ },
260
+ };
261
+
262
+ /** A dark dock around dark tiles; the hovered or focused tile glows in its brand colour. */
263
+ export const RecipeDarkDock: Story = {
264
+ name: "Recipe: Dark Dock with Glow",
265
+ args: { items: darkBrandItems, styleClassPassthrough: ["demo-social-dark-dock"] },
266
+ render: recipeStory("background: #16171b;"),
267
+ parameters: {
268
+ docs: {
269
+ description: {
270
+ story:
271
+ "The dock is the list root itself (padding, border, radius and `inline-size: fit-content` on the passthrough class). The glow is " +
272
+ "`--social-icons-list-link-shadow-hover` built from the brand colour with `color-mix()`. X and TikTok use white and cyan here, since black wouldn't show.",
273
+ },
274
+ },
275
+ },
276
+ };
277
+
278
+ /** Visible labels with the icon in a brand-coloured circle. */
279
+ export const RecipeLabelledPills: Story = {
280
+ name: "Recipe: Labelled Pills",
281
+ args: { items: brandItems, showLabel: true, styleClassPassthrough: ["demo-social-pills"] },
282
+ render: recipeStory("background: #f5f4f0;"),
283
+ parameters: {
284
+ docs: {
285
+ description: {
286
+ story:
287
+ "`show-label` adds each `networkName` as visible text; the circle is the icon wrapper (`--social-icons-list-icon-wrapper-padding`, `-radius`, " +
288
+ "and `-background` from the brand colour). The links keep their `aria-label`, which contains the visible name (WCAG 2.5.3).",
289
+ },
290
+ },
291
+ },
292
+ };
293
+
294
+ /** Overlapping circles that fan out when the row is hovered or a link has keyboard focus. */
295
+ export const RecipeOverlappingStack: Story = {
296
+ name: "Recipe: Overlapping Stack",
297
+ args: { items: brandItems, styleClassPassthrough: ["demo-social-stack"] },
298
+ render: recipeStory(
299
+ "background: #ffffff;",
300
+ `<span style="font-size: 1.6rem; font-weight: 600; color: #4a4b52;">Follow us</span>`
301
+ ),
302
+ parameters: {
303
+ docs: {
304
+ description: {
305
+ story:
306
+ "Negative `margin-inline-start` on every item after the first, reset on `:hover`/`:focus-within` of the list, so keyboard users get the fan-out too " +
307
+ "(no transition under reduced motion). \"Follow us\" is the consumer's own text beside the component, not part of it.",
308
+ },
309
+ },
310
+ },
311
+ };
312
+
313
+ /** The scoped `icon` and `label` slots: custom markup per item. */
314
+ export const CustomSlots: Story = {
315
+ name: "Custom Slots",
316
+ args: { items: brandItems, styleClassPassthrough: ["demo-social-pills"] },
317
+ render: (args) => ({
318
+ components: { SocialIconsList },
319
+ setup() {
320
+ return { args };
321
+ },
322
+ template: `
323
+ <div style="padding: 4rem 2.4rem; border-radius: 2.4rem; background: #f5f4f0; font-family: sans-serif;">
324
+ <SocialIconsList v-bind="args">
325
+ <template #icon="{ item }">
326
+ <span style="font-size: 0.8em; font-weight: 700; color: #ffffff; line-height: 1;">{{ Array.from(item.networkName)[0] }}</span>
327
+ </template>
328
+ <template #label="{ item }">
329
+ {{ item.networkName }} <small style="font-weight: 400;">@{{ item.profileId }}</small>
330
+ </template>
331
+ </SocialIconsList>
332
+ </div>
333
+ `,
334
+ }),
335
+ parameters: {
336
+ docs: {
337
+ description: {
338
+ story:
339
+ "The `icon` slot replaces the icon inside the wrapper (here the network's first letter), and the `label` slot replaces the visible text " +
340
+ "(filling it shows the label without `show-label`). Both receive `{ item }`. Slot content sits inside the link, so keep it non-interactive.",
341
+ },
342
+ },
343
+ },
344
+ };
@@ -0,0 +1,108 @@
1
+ /*
2
+ * SocialIconsList recipes, as a consumer would write them: unlayered, on a class passed through
3
+ * style-class-passthrough. Tokens that read --social-icons-list-brand-colour are set on
4
+ * .social-icons-list-item, the element that defines it (a var() in a custom property resolves where
5
+ * that property is declared, so setting them on the root would find no brand colour).
6
+ */
7
+
8
+ /* 2a: mono tiles, brand colour on hover */
9
+ .demo-social-mono-tiles {
10
+ --social-icons-list-gap: 1rem;
11
+ --social-icons-list-icon-size: 2rem;
12
+ --social-icons-list-icon-colour: #16171b;
13
+ --social-icons-list-link-padding: 1.2rem;
14
+ --social-icons-list-link-radius: 1.2rem;
15
+ --social-icons-list-link-background: #f1f2f5;
16
+ --social-icons-list-link-background-hover: #e6e8ee;
17
+ --social-icons-list-hover-scale: 1.04;
18
+ --social-icons-list-hover-opacity: 1;
19
+
20
+ .social-icons-list-item {
21
+ --social-icons-list-icon-colour-hover: var(--social-icons-list-brand-colour);
22
+ }
23
+ }
24
+
25
+ /* 2b: dark dock with glow */
26
+ .demo-social-dark-dock {
27
+ inline-size: fit-content;
28
+ padding: 0.8rem;
29
+ border: 1px solid #34353b;
30
+ border-radius: 2.2rem;
31
+ background: #232428;
32
+ --social-icons-list-gap: 0.8rem;
33
+ --social-icons-list-icon-size: 2rem;
34
+ --social-icons-list-icon-colour: #ffffff;
35
+ --social-icons-list-link-padding: 1.2rem;
36
+ --social-icons-list-link-radius: 1.4rem;
37
+ --social-icons-list-link-background: #1a1b1f;
38
+ --social-icons-list-hover-scale: 1;
39
+ --social-icons-list-hover-opacity: 1;
40
+
41
+ .social-icons-list-item {
42
+ --social-icons-list-link-shadow-hover:
43
+ 0 0 0 1px color-mix(in oklab, var(--social-icons-list-brand-colour) 60%, transparent),
44
+ 0 0 1.6rem color-mix(in oklab, var(--social-icons-list-brand-colour) 55%, transparent);
45
+ --social-icons-list-icon-colour-hover: var(--social-icons-list-brand-colour);
46
+ }
47
+ }
48
+
49
+ /* 2c: labelled pills (show-label) */
50
+ .demo-social-pills {
51
+ justify-content: center;
52
+ --social-icons-list-gap: 1rem;
53
+ --social-icons-list-icon-size: 1.6rem;
54
+ --social-icons-list-icon-colour: #ffffff;
55
+ --social-icons-list-link-padding: 0.4rem 1.4rem 0.4rem 0.4rem;
56
+ --social-icons-list-link-gap: 0.8rem;
57
+ --social-icons-list-link-border: 1.5px solid #16171b;
58
+ --social-icons-list-link-radius: 99rem;
59
+ --social-icons-list-link-background: #ffffff;
60
+ --social-icons-list-label-colour: #16171b;
61
+ --social-icons-list-icon-wrapper-padding: 0.6rem;
62
+ --social-icons-list-icon-wrapper-radius: 50%;
63
+ --social-icons-list-hover-scale: 1.03;
64
+ --social-icons-list-hover-opacity: 1;
65
+
66
+ .social-icons-list-item {
67
+ --social-icons-list-icon-wrapper-background: var(--social-icons-list-brand-colour);
68
+ }
69
+ }
70
+
71
+ /* 2d: overlapping stack, fans out on hover or keyboard focus */
72
+ .demo-social-stack {
73
+ --social-icons-list-gap: 0;
74
+ --social-icons-list-icon-size: 1.8rem;
75
+ --social-icons-list-icon-colour: #ffffff;
76
+ --social-icons-list-icon-wrapper-padding: 1rem;
77
+ --social-icons-list-icon-wrapper-radius: 50%;
78
+ --social-icons-list-icon-wrapper-border: 2px solid #ffffff;
79
+ --social-icons-list-link-radius: 50%;
80
+ --social-icons-list-hover-scale: 1.08;
81
+ --social-icons-list-hover-opacity: 1;
82
+
83
+ .social-icons-list-item {
84
+ --social-icons-list-icon-wrapper-background: var(--social-icons-list-brand-colour);
85
+
86
+ & + .social-icons-list-item {
87
+ margin-inline-start: -1.2rem;
88
+ }
89
+
90
+ &:hover,
91
+ &:focus-within {
92
+ z-index: 1;
93
+ }
94
+ }
95
+
96
+ &:hover,
97
+ &:focus-within {
98
+ .social-icons-list-item + .social-icons-list-item {
99
+ margin-inline-start: 0.4rem;
100
+ }
101
+ }
102
+
103
+ @media (prefers-reduced-motion: no-preference) {
104
+ .social-icons-list-item {
105
+ transition: margin-inline-start 250ms ease-out;
106
+ }
107
+ }
108
+ }