srcdev-nuxt-components 9.6.0 → 9.6.1

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 (107) hide show
  1. package/.claude/skills/component-compliance-checklist.md +8 -2
  2. package/.claude/skills/component-dynamic-slots.md +3 -3
  3. package/.claude/skills/component-export-types.md +10 -16
  4. package/.claude/skills/component-naming.md +1 -1
  5. package/.claude/skills/components/alert-content.md +9 -1
  6. package/.claude/skills/components/container-glow.md +21 -8
  7. package/.claude/skills/components/display-avatar.md +13 -7
  8. package/.claude/skills/components/display-chip.md +37 -24
  9. package/.claude/skills/components/display-pill.md +58 -10
  10. package/.claude/skills/components/display-prompt.md +8 -5
  11. package/.claude/skills/components/display-toast.md +21 -4
  12. package/.claude/skills/components/glowing-border.md +20 -2
  13. package/.claude/skills/components/responsive-header.md +11 -1
  14. package/.claude/skills/components/select-menu.md +54 -5
  15. package/.claude/skills/components/site-header.md +2 -2
  16. package/.claude/skills/components/tabbed-content.md +300 -0
  17. package/.claude/skills/composable-anchor-scroll.md +16 -15
  18. package/.claude/skills/index.md +4 -4
  19. package/.claude/skills/storybook-add-story.md +53 -0
  20. package/.claude/skills/theming-status-tokens.md +60 -0
  21. package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
  22. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  23. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  24. package/.vscode/srcdev-component-responsive-header.code-snippets +6 -0
  25. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  26. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  27. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  28. package/app/assets/styles/setup/03.theming/index.css +1 -0
  29. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  30. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  31. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  32. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  33. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  34. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  35. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  36. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  37. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  38. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  39. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +75 -9
  40. package/app/components/01.atoms/display-pill/DisplayPill.vue +57 -24
  41. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  42. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  43. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  44. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  45. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  46. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  47. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  48. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  49. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  50. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  51. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  52. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  53. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
  54. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
  55. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
  56. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
  57. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
  58. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
  59. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  60. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  61. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  62. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  63. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  64. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  65. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  66. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  67. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  68. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  69. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  70. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  71. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  72. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  73. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  74. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  75. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  76. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  77. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  78. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  79. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +38 -11
  80. package/app/components/03.organisms/responsive-header/NavigationItems.vue +27 -12
  81. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +35 -20
  82. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +1 -1
  83. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +1 -1
  84. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  85. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  86. package/app/composables/useTabs.ts +148 -206
  87. package/app/types/components/display-chip.d.ts +8 -4
  88. package/app/types/components/display-toast.d.ts +2 -0
  89. package/app/types/components/index.ts +0 -1
  90. package/app/types/components/select-menu.d.ts +2 -0
  91. package/app/utils/pausableTimeout.ts +41 -0
  92. package/nuxt.config.ts +1 -1
  93. package/package.json +1 -1
  94. package/.claude/skills/components/tab-navigation.md +0 -288
  95. package/.claude/skills/components/tabs-core.md +0 -187
  96. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  97. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  98. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  99. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  100. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  101. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -191
  102. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -550
  103. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -259
  104. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -379
  105. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  106. package/app/composables/useNavCollapse.ts +0 -86
  107. package/app/types/components/nav-item.d.ts +0 -11
@@ -1,5 +1,10 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import { computed, ref } from "vue";
2
3
  import StorybookComponent from "../GlowingBorder.vue";
4
+ import InputTextCore from "../../../../05.forms/input-text/InputTextCore.vue";
5
+ import InputButton from "../../../../05.forms/input-button/InputButton.vue";
6
+ import CanvasSwitcher from "../../../canvas-switcher/CanvasSwitcher.vue";
7
+ import type { MediaCanvas } from "~/types/components";
3
8
 
4
9
  const PX_OPTIONS = ["1px", "2px", "3px", "4px", "5px", "6px"] as const;
5
10
  const DURATION_OPTIONS = ["5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"] as const;
@@ -11,8 +16,24 @@ type StoryArgs = {
11
16
  borderWidth: (typeof PX_OPTIONS)[number];
12
17
  borderRadius: (typeof PX_OPTIONS)[number];
13
18
  animationDuration: (typeof DURATION_OPTIONS)[number];
19
+ canvasBackground: string;
20
+ surface: string;
14
21
  };
15
22
 
23
+ const darkColours = ["#000000", "#020024", "#0f0f23", "#1e293b", "#262626"];
24
+ const darkColourLabels = {
25
+ "": "Theme default (light)",
26
+ "#000000": "Black",
27
+ "#020024": "Midnight",
28
+ "#0f0f23": "Ink",
29
+ "#1e293b": "Slate",
30
+ "#262626": "Charcoal",
31
+ };
32
+ const surfaceLabels = { ...darkColourLabels, "#ffffff": "White" };
33
+ const isDark = (colour: string) => darkColours.includes(colour);
34
+
35
+ const tokenCategory = "CSS tokens (story only, set in your CSS)";
36
+
16
37
  export default {
17
38
  title: "Atoms/Effects/GlowingBorder",
18
39
  component: StorybookComponent,
@@ -29,25 +50,41 @@ export default {
29
50
  },
30
51
  content: {
31
52
  control: { type: "text" },
32
- description: "Slot content",
53
+ description: "**Story control, not a prop.** Text placed in the default slot.",
54
+ table: { category: "Slot content (story only)" },
33
55
  },
34
56
  borderWidth: {
35
57
  control: { type: "select" },
36
58
  options: PX_OPTIONS,
37
- description: "--glowing-border-width",
38
- table: { category: "CSS tokens" },
59
+ description: "**Story control, not a prop.** Sets `--glowing-border-width`. In your CSS: `.promo { --glowing-border-width: 4px; }`",
60
+ table: { category: tokenCategory },
39
61
  },
40
62
  borderRadius: {
41
63
  control: { type: "select" },
42
64
  options: PX_OPTIONS,
43
- description: "--glowing-border-radius",
44
- table: { category: "CSS tokens" },
65
+ description: "**Story control, not a prop.** Sets `--glowing-border-radius`. In your CSS: `.promo { --glowing-border-radius: 6px; }`",
66
+ table: { category: tokenCategory },
45
67
  },
46
68
  animationDuration: {
47
69
  control: { type: "select" },
48
70
  options: DURATION_OPTIONS,
49
- description: "--glowing-border-animation-duration",
50
- table: { category: "CSS tokens" },
71
+ description:
72
+ "**Story control, not a prop.** Sets `--glowing-border-animation-duration`. In your CSS: `.promo { --glowing-border-animation-duration: 6s; }`",
73
+ table: { category: tokenCategory },
74
+ },
75
+ surface: {
76
+ control: { type: "select", labels: surfaceLabels },
77
+ options: ["", "#ffffff", ...darkColours],
78
+ description:
79
+ "**Story control, not a prop.** Sets `--glowing-border-surface` (and a light `--glowing-border-text-colour` for dark values). In your CSS: `.promo { --glowing-border-surface: #0f0f23; --glowing-border-text-colour: #f3f4f6; }`",
80
+ table: { category: tokenCategory },
81
+ },
82
+ canvasBackground: {
83
+ control: { type: "select", labels: darkColourLabels },
84
+ options: ["", ...darkColours],
85
+ description:
86
+ "**Story control, not a prop.** Background of the demo stage, so the glow can be checked on a dark page.",
87
+ table: { category: "Story canvas (story only)" },
51
88
  },
52
89
  styleClassPassthrough: {
53
90
  table: { disable: true },
@@ -66,26 +103,43 @@ export default {
66
103
  borderWidth: "3px",
67
104
  borderRadius: "6px",
68
105
  animationDuration: "10s",
106
+ canvasBackground: "",
107
+ surface: "",
108
+ },
109
+ parameters: {
110
+ docs: {
111
+ description: {
112
+ component:
113
+ "Wraps its default slot in an animated conic-gradient glow border. The canvas and surface controls are story-only, for checking the glow against dark themes.",
114
+ },
115
+ },
69
116
  },
70
117
  } as Meta<StoryArgs>;
71
118
 
119
+ function useStorySetup(args: StoryArgs) {
120
+ const stageStyle = computed(() => ({
121
+ background: args.canvasBackground || undefined,
122
+ }));
123
+ const tokenStyle = computed(() => ({
124
+ "--glowing-border-width": args.borderWidth,
125
+ "--glowing-border-radius": args.borderRadius,
126
+ "--glowing-border-animation-duration": args.animationDuration,
127
+ "--glowing-border-surface": args.surface || undefined,
128
+ "--glowing-border-text-colour":
129
+ isDark(args.surface) ? "#f3f4f6" : undefined,
130
+ }));
131
+ return { args, stageStyle, tokenStyle };
132
+ }
133
+
72
134
  const Template: StoryFn<StoryArgs> = (args) => ({
73
135
  components: { StorybookComponent },
74
136
  setup() {
75
- return { args };
137
+ return useStorySetup(args);
76
138
  },
77
139
  template: `
78
- <div style="padding: 40px; background-color: var(--slate-10);">
79
- <StorybookComponent
80
- :tag="args.tag"
81
- :variant="args.variant"
82
- :style="{
83
- '--glowing-border-width': args.borderWidth,
84
- '--glowing-border-radius': args.borderRadius,
85
- '--glowing-border-animation-duration': args.animationDuration,
86
- }"
87
- >
88
- <div style="padding: 20px; color: var(--slate-02);">
140
+ <div style="padding: 40px;" :style="stageStyle">
141
+ <StorybookComponent :tag="args.tag" :variant="args.variant" :style="tokenStyle">
142
+ <div style="padding: 20px;">
89
143
  <h3 style="margin: 0 0 12px 0; font-size: 1.5rem; font-weight: 600;">GlowingBorder</h3>
90
144
  <p style="margin: 0; line-height: 1.6;">{{ args.content }}</p>
91
145
  </div>
@@ -127,39 +181,144 @@ Steel.args = {
127
181
  export const Green = Template.bind({});
128
182
  Green.args = {
129
183
  variant: "green",
130
- content:
131
- "Green variant cycles through vibrant, saturated greens for a fresh, energetic glow effect.",
184
+ content: "Green variant cycles through vibrant, saturated greens for a fresh, energetic glow effect.",
185
+ };
186
+
187
+ export const CustomTokens = Template.bind({});
188
+ CustomTokens.args = {
189
+ variant: "vivid",
190
+ content: "Border width, radius, and animation speed all driven by the CSS token controls.",
191
+ borderWidth: "6px",
192
+ borderRadius: "4px",
193
+ animationDuration: "5s",
194
+ };
195
+
196
+ export const HighlightedControls: StoryFn<StoryArgs> = (args) => ({
197
+ components: { StorybookComponent, InputTextCore, InputButton },
198
+ setup() {
199
+ const { args: storyArgs, stageStyle } = useStorySetup(args);
200
+ const glowStyle = (controlRadius: string) =>
201
+ computed(() => ({
202
+ "--glowing-border-width": args.borderWidth,
203
+ "--glowing-border-radius": `calc(${controlRadius} + ${args.borderWidth})`,
204
+ "--glowing-border-animation-duration": args.animationDuration,
205
+ "--glowing-border-surface": "transparent",
206
+ "--glowing-border-overflow": "visible",
207
+ display: "inline-block",
208
+ }));
209
+ const buttonGlowStyle = glowStyle("var(--button-border-radius)");
210
+ const inputGlowStyle = computed(() => ({
211
+ ...glowStyle("var(--form-input-border-radius)").value,
212
+ "--input-text-border": "transparent",
213
+ display: "block",
214
+ }));
215
+ const email = ref("");
216
+ return { args: storyArgs, stageStyle, buttonGlowStyle, inputGlowStyle, email };
217
+ },
218
+ template: `
219
+ <div style="padding: 40px; display: grid; gap: 32px; justify-items: start;" :style="stageStyle">
220
+ <StorybookComponent tag="span" :variant="args.variant" :style="buttonGlowStyle">
221
+ <InputButton type="button" variant="primary" button-text="Book a free consultation"></InputButton>
222
+ </StorybookComponent>
223
+
224
+ <div style="display: grid; gap: 8px; inline-size: min(100%, 40rem);">
225
+ <label for="highlighted-email">Early-access email</label>
226
+ <StorybookComponent :variant="args.variant" :style="inputGlowStyle">
227
+ <InputTextCore
228
+ id="highlighted-email"
229
+ v-model="email"
230
+ type="email"
231
+ name="highlighted-email"
232
+ placeholder="eg. jane@example.com"
233
+ ></InputTextCore>
234
+ </StorybookComponent>
235
+ </div>
236
+ </div>
237
+ `,
238
+ });
239
+ HighlightedControls.args = {
240
+ variant: "vivid",
241
+ borderWidth: "3px",
242
+ };
243
+ HighlightedControls.parameters = {
244
+ docs: {
245
+ description: {
246
+ story:
247
+ "The glow replaces a single control's border, for when a design calls out something special. No changes to the controls: " +
248
+ "InputButton's border already matches its surface, and the input gets `--input-text-border: transparent`. On the GlowingBorder, " +
249
+ "the radius is the control's radius plus the glow width, the surface is transparent, and `--glowing-border-overflow: visible` " +
250
+ "keeps the controls' hover and focus outlines from being clipped. Tab through to check the focus rings.",
251
+ },
252
+ },
132
253
  };
133
254
 
134
- const CustomTokensTemplate: StoryFn<StoryArgs> = (args) => ({
255
+ export const StressTest: StoryFn<StoryArgs> = (args) => ({
135
256
  components: { StorybookComponent },
136
257
  setup() {
137
- return { args };
258
+ const longUnbroken = "Donaudampfschifffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft";
259
+ const longUrl = "https://example.com/" + "a-very-long-path-segment-without-spaces".repeat(4);
260
+ const longCopy =
261
+ "Unsere Behandlungen werden individuell auf Ihre Bedürfnisse abgestimmt, damit Sie sich rundum wohlfühlen und entspannt nach Hause gehen können. ".repeat(
262
+ 6
263
+ );
264
+ return { ...useStorySetup(args), longUnbroken, longUrl, longCopy };
138
265
  },
139
266
  template: `
140
- <div style="padding: 40px; background-color: var(--slate-10);">
141
- <StorybookComponent
142
- :tag="args.tag"
143
- :variant="args.variant"
144
- :style="{
145
- '--glowing-border-width': args.borderWidth,
146
- '--glowing-border-radius': args.borderRadius,
147
- '--glowing-border-animation-duration': args.animationDuration,
148
- }"
149
- >
150
- <div style="padding: 20px; color: var(--slate-02);">
151
- <p style="margin: 0; line-height: 1.6;">{{ args.content }}</p>
267
+ <div style="padding: 24px 16px; display: grid; gap: 24px;" :style="stageStyle">
268
+ <StorybookComponent :tag="args.tag" :variant="args.variant" :style="tokenStyle">
269
+ <div style="padding: 20px;">
270
+ <h3 style="margin: 0 0 12px 0;">{{ longUnbroken }}</h3>
271
+ <p style="margin: 0 0 12px 0;">{{ longUrl }}</p>
272
+ <p style="margin: 0;">{{ longCopy }}</p>
152
273
  </div>
153
274
  </StorybookComponent>
275
+ <StorybookComponent :tag="args.tag" :variant="args.variant" :style="tokenStyle"></StorybookComponent>
276
+ <StorybookComponent :tag="args.tag" :variant="args.variant" :style="tokenStyle">A</StorybookComponent>
277
+ <StorybookComponent :tag="args.tag" :variant="args.variant" :style="tokenStyle">
278
+ <p style="margin: 0; padding: 12px;">🎉 Emoji first, then &lt;b&gt;html&lt;/b&gt; as text</p>
279
+ </StorybookComponent>
280
+ <StorybookComponent :tag="args.tag" :variant="args.variant" :style="tokenStyle">
281
+ <p dir="rtl" style="margin: 0; padding: 12px;">نص عربي طويل من اليمين إلى اليسار للتحقق من الاتجاه</p>
282
+ </StorybookComponent>
283
+ <p style="margin: 0;">
284
+ Inline use:
285
+ <StorybookComponent tag="span" :variant="args.variant" :style="tokenStyle">{{ longUnbroken }}</StorybookComponent>
286
+ sits in running text.
287
+ </p>
154
288
  </div>
155
289
  `,
156
290
  });
157
-
158
- export const CustomTokens = CustomTokensTemplate.bind({});
159
- CustomTokens.args = {
160
- variant: "vivid",
161
- content: "Border width, radius, and animation speed all driven by the controls above.",
291
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
292
+ StressTest.args = {
162
293
  borderWidth: "6px",
163
- borderRadius: "4px",
164
- animationDuration: "5s",
294
+ borderRadius: "6px",
295
+ };
296
+ StressTest.decorators = [
297
+ (story, context) => ({
298
+ components: { story, CanvasSwitcher },
299
+ setup() {
300
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
301
+ return { canvasName };
302
+ },
303
+ template: `
304
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
305
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
306
+ </div>
307
+ <div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
308
+ <story />
309
+ </div>
310
+ `,
311
+ }),
312
+ ];
313
+ StressTest.parameters = {
314
+ initialCanvas: "mobileCanvas",
315
+ docs: {
316
+ description: {
317
+ story:
318
+ "Oversized and odd slot content. Long unbroken words and URLs should wrap inside the border rather than being clipped " +
319
+ "(the root has overflow: hidden), long German copy should grow the box, the empty one should collapse to just its border, " +
320
+ "HTML-like text should render as text, RTL text should align right, and the inline span should wrap within running text. " +
321
+ "Check at every canvas width and with each variant.",
322
+ },
323
+ },
165
324
  };
@@ -52,19 +52,30 @@ describe("GlowingBorder", () => {
52
52
 
53
53
  // ─── Variant ──────────────────────────────────────────────────────────────
54
54
 
55
- it("applies subtle variant class by default", async () => {
55
+ it("sets data-variant='subtle' by default", async () => {
56
56
  const wrapper = await mountSuspended(GlowingBorder);
57
- expect(wrapper.classes()).toContain("subtle");
57
+ expect(wrapper.attributes("data-variant")).toBe("subtle");
58
58
  });
59
59
 
60
60
  it.each(["subtle", "vivid", "silver", "steel", "green"] as const)(
61
- "applies %s variant class when variant='%s'",
61
+ "sets data-variant='%s' when variant='%s'",
62
62
  async (variant) => {
63
63
  const wrapper = await mountSuspended(GlowingBorder, { props: { variant } });
64
- expect(wrapper.classes()).toContain(variant);
64
+ expect(wrapper.attributes("data-variant")).toBe(variant);
65
65
  }
66
66
  );
67
67
 
68
+ it("does not add the variant name as a class, so consumer classes like .green can't collide", async () => {
69
+ const wrapper = await mountSuspended(GlowingBorder, { props: { variant: "green" } });
70
+ expect(wrapper.classes()).not.toContain("green");
71
+ });
72
+
73
+ it("updates data-variant when the variant prop changes", async () => {
74
+ const wrapper = await mountSuspended(GlowingBorder, { props: { variant: "vivid" } });
75
+ await wrapper.setProps({ variant: "steel" });
76
+ expect(wrapper.attributes("data-variant")).toBe("steel");
77
+ });
78
+
68
79
  // ─── styleClassPassthrough ────────────────────────────────────────────────
69
80
 
70
81
  it("applies a single styleClassPassthrough string", async () => {
@@ -1,3 +1,3 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
- exports[`GlowingBorder > renders correct HTML structure (default) 1`] = `"<div class="glowing-border subtle">Content</div>"`;
3
+ exports[`GlowingBorder > renders correct HTML structure (default) 1`] = `"<div class="glowing-border" data-variant="subtle">Content</div>"`;
@@ -17,9 +17,9 @@ the base token restyles every variant, and a variant token still wins for that o
17
17
  | `--display-pill-background` | `var(--slate-01)` | Background (the `default` variant, and the fallback for every other variant) |
18
18
  | `--display-pill-text-colour` | `var(--slate-09)` | Text/icon colour (same fallback role) |
19
19
  | `--display-pill-primary-background` / `-text-colour` | `var(--blue-01)` / `var(--blue-09)` | `primary` variant |
20
- | `--display-pill-success-background` / `-text-colour` | `var(--green-01)` / `var(--green-10)` | `success` variant |
21
- | `--display-pill-warning-background` / `-text-colour` | `var(--orange-01)` / `var(--orange-10)` | `warning` variant |
22
- | `--display-pill-danger-background` / `-text-colour` | `var(--red-01)` / `var(--red-10)` | `danger` variant |
20
+ | `--display-pill-success-background` / `-text-colour` | `var(--status-success-surface)` / `var(--status-success-text)` | `success` variant |
21
+ | `--display-pill-warning-background` / `-text-colour` | `var(--status-warning-surface)` / `var(--status-warning-text)` | `warning` variant |
22
+ | `--display-pill-danger-background` / `-text-colour` | `var(--status-danger-surface)` / `var(--status-danger-text)` | `danger` variant |
23
23
  | `--display-pill-neutral-background` / `-text-colour` | `var(--slate-08)` / `var(--slate-03)` | `neutral` variant (dark) |
24
24
  | `--display-pill-focus-ring` | `var(--theme-border-focus)` | `:focus-visible` outline on `button`/`a` pills |
25
25
 
@@ -29,16 +29,26 @@ the base token restyles every variant, and a variant token still wins for that o
29
29
  > `--yellow-*` ramp that doesn't exist, so it always showed its hardcoded hex fallbacks; it now uses
30
30
  > the `orange` ramp the warning theme moved to.
31
31
 
32
+ `success`, `warning` and `danger` default to the global `--status-*` tokens
33
+ (`03.theming/_status.css`), so overriding those restyles pills along with every other status
34
+ indicator; same colours as before (changed 2026-10-07). `primary` (brand) and `neutral` (a dark,
35
+ inverted pill) keep their own defaults and don't read the status set. See
36
+ `.claude/skills/theming-status-tokens.md`.
37
+
32
38
  ### Border, outline and shape
33
39
 
34
40
  | Token | Default | Controls |
35
41
  |---|---|---|
36
- | `--display-pill-border-colour` | `transparent` | Border colour |
42
+ | `--display-pill-border-colour` | `transparent` | Border colour (every variant's fallback) |
43
+ | `--display-pill-<variant>-border-colour` | the base border colour | Border colour for one variant (`primary`, `success`, `warning`, `danger`, `neutral`) |
37
44
  | `--display-pill-border-width` | `0.1rem` | Border width |
38
45
  | `--display-pill-border-style` | `solid` | Border style |
39
46
  | `--display-pill-border-radius` | `100vw` | Radius (100vw is always a full pill) |
40
47
  | `--display-pill-outline` | `none` | Decorative outline (shorthand) on every pill |
41
48
  | `--display-pill-outline-offset` | `0` | Offset for that outline |
49
+ | `--display-pill-ring-width` | `0` | Width of a ring drawn just outside the border (a `box-shadow`, so it follows the radius on every browser and takes no layout space) |
50
+ | `--display-pill-<variant>-ring-colour` | the base ring colour | Ring colour for one variant (`primary`, `success`, `warning`, `danger`, `neutral`) |
51
+ | `--display-pill-ring-colour` | the pill's resolved background | Ring colour. The default follows each variant's background, so `border-colour: currentColor` plus a ring gives a two-tone border in every variant's own colours |
42
52
 
43
53
  ### Size
44
54
 
@@ -50,6 +60,16 @@ the base token restyles every variant, and a variant token still wins for that o
50
60
 
51
61
  The unsuffixed token is the `md` value; `-sm`/`-lg` apply to those sizes.
52
62
 
63
+ ### Label
64
+
65
+ | Token | Default | Controls |
66
+ |---|---|---|
67
+ | `--display-pill-label-line-clamp` | `1` | Lines of label text before an ellipsis. `1` keeps the pill on one line; `none` lets a long label wrap; any other number caps the lines |
68
+
69
+ Applies to the `label` prop and to default-slot content alike. Clamped text stays in the DOM, so
70
+ screen readers still read all of it. If a pill's text must stay fully readable (a price, say) in a
71
+ narrow space, set `none`.
72
+
53
73
  > **Changed 2026-09-27**: the icon-size tokens were declared but never used, so icons weren't
54
74
  > sized. The icon slot is now wrapped in `.display-pill-icon`, which sets its `font-size`.
55
75
 
@@ -70,23 +90,35 @@ keeps the same proportions. Tune a **ratio** to change spacing across all sizes,
70
90
  > `-lg`, etc., now removed). It's now derived from the icon size; results are within about 0.1rem
71
91
  > of the old values, except `lg` block padding (`0.6rem` → `0.48rem`), which was out of proportion.
72
92
 
73
- Private tokens (not public API): `--_background`, `--_text-colour`, `--_font-size`, `--_padding-inline`,
74
- `--_padding-block`, `--_padding-inline-icon` (derived from `--_icon-size`), `--_icon-size`, swapped by the size and variant classes.
93
+ Private tokens (not public API): `--_background`, `--_text-colour`, `--_border-colour`, `--_ring-colour`, `--_font-size`, `--_padding-inline`,
94
+ `--_padding-block`, `--_padding-inline-icon` (derived from `--_icon-size`), `--_icon-size`, swapped by the size and variant attributes.
75
95
 
76
96
  > **Changed 2026-10-06**: a pill is capped at its container's width (`max-inline-size: 100%`), and a
77
97
  > label too long for it ends in an ellipsis instead of overflowing. Short labels are unaffected.
98
+ >
99
+ > **Changed 2026-10-08**: default-slot text is now wrapped in `.display-pill-label` too, so it
100
+ > ellipsizes like a `label` prop instead of spilling out of the pill. The ellipsis is now a
101
+ > line clamp (`--display-pill-label-line-clamp`, default `1`) rather than `white-space: nowrap`,
102
+ > so a long label breaks at a space before the ellipsis rather than mid-word. A pill with no text
103
+ > and no icon (empty or whitespace-only `label`, no slots) no longer renders a blank coloured blob.
78
104
 
79
105
  ## State hooks
80
106
 
81
- - Size class: `.sm`, `.md`, `.lg`. Variant class: `.default`, `.primary`, `.success`, `.warning`,
82
- `.danger`, `.neutral`. `.is-reversed` when `reversed`.
107
+ - `data-size`: `sm`, `md`, `lg`. `data-variant`: `default`, `primary`, `success`, `warning`,
108
+ `danger`, `neutral` (an unknown value gets the default colours). `data-reversed` when `reversed`.
83
109
  - `data-icon-position`: `start` (icon before the text), `end` (`reversed`), `only` (icon with no
84
110
  label or text), or absent (no icon). It picks which side gets `--display-pill-padding-inline-icon`.
85
111
  - Classes: `.display-pill` (root), `.display-pill-icon` (icon slot wrapper, only when used),
86
- `.display-pill-label` (only with the `label` prop).
112
+ `.display-pill-label` (wraps the `label` prop or default-slot content, absent when there's no text).
87
113
  - `button`/`a` roots get a pointer cursor and a `:focus-visible` outline.
88
114
 
89
115
  > **Changed 2026-09-27**: `.pill-label` → `.display-pill-label`.
116
+ >
117
+ > **Changed 2026-10-08**: size, variant and reversed were bare classes (`.sm`, `.primary`,
118
+ > `.success`, `.is-reversed`...), which a consumer's own utility classes of the same name could
119
+ > match, since the pill renders inside the consumer's DOM. Replace `.display-pill.primary` with
120
+ > `.display-pill[data-variant="primary"]`, `.display-pill.sm` with `.display-pill[data-size="sm"]`,
121
+ > and `.display-pill.is-reversed` with `.display-pill[data-reversed]`.
90
122
 
91
123
  ## Local overrides
92
124
 
@@ -100,6 +132,19 @@ component need `:deep()`. Patterns and examples: `.claude/skills/component-local
100
132
  won't reach those pills. `ServiceSummary` and `ServiceDetail` set `--display-pill-background`,
101
133
  `-text-colour` and `-border-colour` on their pill rows; use their own `--service-*-pill-*` tokens.
102
134
 
135
+ ## Recipe: two-tone ringed pills
136
+
137
+ A 1px border in each variant's dark text colour, inside a 2px ring in its light background colour:
138
+
139
+ ```css
140
+ .tag-list {
141
+ --display-pill-border-colour: currentColor;
142
+ --display-pill-ring-width: 0.2rem;
143
+ }
144
+ ```
145
+
146
+ The `neutral` variant is inverted (light text on dark), so its border comes out light and its ring dark.
147
+
103
148
  ## Recipe: outlined pills
104
149
 
105
150
  ```css
@@ -110,6 +155,27 @@ won't reach those pills. `ServiceSummary` and `ServiceDetail` set `--display-pil
110
155
  }
111
156
  ```
112
157
 
158
+ That gives every variant the surrounding text colour. To keep each variant's own colour, leave
159
+ `--display-pill-text-colour` unset and set the per-variant text tokens instead. The default text
160
+ steps (`-09`/`-10`) are near-black in every hue, so lift them to `-07` to tell the variants apart
161
+ (the border follows, being `currentColor`):
162
+
163
+ ```css
164
+ .tag-list {
165
+ --display-pill-background: transparent;
166
+ --display-pill-border-colour: currentColor;
167
+ --display-pill-text-colour: var(--slate-07);
168
+ --display-pill-primary-text-colour: var(--blue-07);
169
+ --display-pill-success-text-colour: var(--green-07);
170
+ --display-pill-warning-text-colour: var(--orange-07);
171
+ --display-pill-danger-text-colour: var(--red-07);
172
+ --display-pill-neutral-text-colour: var(--slate-10);
173
+ }
174
+ ```
175
+
176
+ Add a ring in each variant's light `-02` step with `--display-pill-ring-width: 0.2rem` and
177
+ `--display-pill-<variant>-ring-colour` (`var(--blue-02)`, `var(--green-02)`, and so on).
178
+
113
179
  ## Class passthrough
114
180
 
115
181
  `style-class-passthrough` adds classes to the root. Reachable in normal use.
@@ -2,15 +2,20 @@
2
2
  <component
3
3
  :is="tag"
4
4
  class="display-pill"
5
- :class="[size, variant, { 'is-reversed': reversed }, elementClasses]"
5
+ :class="elementClasses"
6
6
  :type="tag === 'button' ? 'button' : undefined"
7
+ :data-size="size"
8
+ :data-variant="variant"
9
+ :data-reversed="reversed || undefined"
7
10
  :data-icon-position="iconPosition()"
8
11
  >
9
12
  <span v-if="slots.icon" class="display-pill-icon">
10
13
  <slot name="icon"></slot>
11
14
  </span>
12
- <span v-if="label" class="display-pill-label">{{ label }}</span>
13
- <slot v-else name="default"></slot>
15
+ <span v-if="hasText()" class="display-pill-label">
16
+ <template v-if="hasLabel">{{ label }}</template>
17
+ <slot v-else name="default"></slot>
18
+ </span>
14
19
  </component>
15
20
  </template>
16
21
 
@@ -35,10 +40,14 @@ const props = withDefaults(defineProps<Props>(), {
35
40
 
36
41
  const slots = useSlots();
37
42
 
38
- // A function, not a computed: slots aren't reactive, so a cached value goes stale when a slot is toggled.
43
+ const hasLabel = computed(() => !!props.label?.trim());
44
+
45
+ // Functions, not computeds: slots aren't reactive, so a cached value goes stale when a slot is toggled.
46
+ const hasText = () => hasLabel.value || !!slots.default;
47
+
39
48
  const iconPosition = () => {
40
49
  if (!slots.icon) return undefined;
41
- if (!props.label && !slots.default) return "only";
50
+ if (!hasText()) return "only";
42
51
  return props.reversed ? "end" : "start";
43
52
  };
44
53
 
@@ -55,6 +64,8 @@ watch(
55
64
  .display-pill {
56
65
  --_background: var(--display-pill-background, var(--slate-01));
57
66
  --_text-colour: var(--display-pill-text-colour, var(--slate-09));
67
+ --_border-colour: var(--display-pill-border-colour, transparent);
68
+ --_ring-colour: var(--display-pill-ring-colour, var(--_background));
58
69
  --_font-size: var(--display-pill-font-size, 1.2rem);
59
70
  --_icon-size: var(--display-pill-icon-size, 1.4rem);
60
71
 
@@ -92,18 +103,19 @@ watch(
92
103
  }
93
104
 
94
105
  border: var(--display-pill-border-width, 0.1rem) var(--display-pill-border-style, solid)
95
- var(--display-pill-border-colour, transparent);
106
+ var(--_border-colour);
96
107
  /* 100vw always resolves to a full pill radius regardless of element size */
97
108
  border-radius: var(--display-pill-border-radius, 100vw);
98
109
  outline: var(--display-pill-outline, none);
99
110
  outline-offset: var(--display-pill-outline-offset, 0);
111
+ /* box-shadow, not outline: outlines ignore border-radius before Safari 16.4. */
112
+ box-shadow: 0 0 0 var(--display-pill-ring-width, 0) var(--_ring-colour);
100
113
  background-color: var(--_background);
101
114
  color: var(--_text-colour);
102
115
  font-family: inherit;
103
116
  font-size: var(--_font-size);
104
117
  font-weight: var(--display-pill-font-weight, 500);
105
118
  line-height: 1;
106
- white-space: nowrap;
107
119
  width: fit-content;
108
120
  max-inline-size: 100%;
109
121
  cursor: default;
@@ -118,57 +130,78 @@ watch(
118
130
  }
119
131
  }
120
132
 
121
- &.is-reversed {
133
+ &:empty {
134
+ display: none;
135
+ }
136
+
137
+ &[data-reversed] {
122
138
  flex-direction: row-reverse;
123
139
  }
124
140
 
125
141
  .display-pill-icon {
126
142
  display: inline-flex;
143
+ flex-shrink: 0;
127
144
  align-items: center;
128
145
  font-size: var(--_icon-size);
129
146
  }
130
147
 
131
148
  .display-pill-label {
132
- display: block;
149
+ display: -webkit-box;
150
+ -webkit-box-orient: vertical;
151
+ -webkit-line-clamp: var(--display-pill-label-line-clamp, 1);
152
+ line-clamp: var(--display-pill-label-line-clamp, 1);
153
+ overflow: hidden;
154
+ overflow-wrap: anywhere;
133
155
  min-inline-size: 0;
134
- overflow-x: clip;
135
- text-overflow: ellipsis;
156
+ /* Room for descenders and accents, which line-height: 1 would otherwise clip. */
157
+ padding-block: 0.15em;
158
+ margin-block: -0.15em;
136
159
  }
137
160
 
138
- &.sm {
161
+ &[data-size="sm"] {
139
162
  --_font-size: var(--display-pill-font-size-sm, 1rem);
140
163
  --_icon-size: var(--display-pill-icon-size-sm, 1.2rem);
141
164
  }
142
165
 
143
- &.lg {
166
+ &[data-size="lg"] {
144
167
  --_font-size: var(--display-pill-font-size-lg, 1.4rem);
145
168
  --_icon-size: var(--display-pill-icon-size-lg, 1.6rem);
146
169
  }
147
170
 
148
171
  /* Variant token, then the base token, then the variant's own default. */
149
- &.primary {
172
+ &[data-variant="primary"] {
150
173
  --_background: var(--display-pill-primary-background, var(--display-pill-background, var(--blue-01)));
151
174
  --_text-colour: var(--display-pill-primary-text-colour, var(--display-pill-text-colour, var(--blue-09)));
175
+ --_border-colour: var(--display-pill-primary-border-colour, var(--display-pill-border-colour, transparent));
176
+ --_ring-colour: var(--display-pill-primary-ring-colour, var(--display-pill-ring-colour, var(--_background)));
152
177
  }
153
178
 
154
- &.success {
155
- --_background: var(--display-pill-success-background, var(--display-pill-background, var(--green-01)));
156
- --_text-colour: var(--display-pill-success-text-colour, var(--display-pill-text-colour, var(--green-10)));
179
+ &[data-variant="success"] {
180
+ --_background: var(--display-pill-success-background, var(--display-pill-background, var(--status-success-surface)));
181
+ --_text-colour: var(--display-pill-success-text-colour, var(--display-pill-text-colour, var(--status-success-text)));
182
+ --_border-colour: var(--display-pill-success-border-colour, var(--display-pill-border-colour, transparent));
183
+ --_ring-colour: var(--display-pill-success-ring-colour, var(--display-pill-ring-colour, var(--_background)));
157
184
  }
158
185
 
159
- &.warning {
160
- --_background: var(--display-pill-warning-background, var(--display-pill-background, var(--orange-01)));
161
- --_text-colour: var(--display-pill-warning-text-colour, var(--display-pill-text-colour, var(--orange-10)));
186
+ &[data-variant="warning"] {
187
+ --_background: var(--display-pill-warning-background, var(--display-pill-background, var(--status-warning-surface)));
188
+ --_text-colour: var(--display-pill-warning-text-colour, var(--display-pill-text-colour, var(--status-warning-text)));
189
+ --_border-colour: var(--display-pill-warning-border-colour, var(--display-pill-border-colour, transparent));
190
+ --_ring-colour: var(--display-pill-warning-ring-colour, var(--display-pill-ring-colour, var(--_background)));
162
191
  }
163
192
 
164
- &.danger {
165
- --_background: var(--display-pill-danger-background, var(--display-pill-background, var(--red-01)));
166
- --_text-colour: var(--display-pill-danger-text-colour, var(--display-pill-text-colour, var(--red-10)));
193
+ &[data-variant="danger"] {
194
+ --_background: var(--display-pill-danger-background, var(--display-pill-background, var(--status-danger-surface)));
195
+ --_text-colour: var(--display-pill-danger-text-colour, var(--display-pill-text-colour, var(--status-danger-text)));
196
+ --_border-colour: var(--display-pill-danger-border-colour, var(--display-pill-border-colour, transparent));
197
+ --_ring-colour: var(--display-pill-danger-ring-colour, var(--display-pill-ring-colour, var(--_background)));
167
198
  }
168
199
 
169
- &.neutral {
200
+ &[data-variant="neutral"] {
170
201
  --_background: var(--display-pill-neutral-background, var(--display-pill-background, var(--slate-08)));
171
202
  --_text-colour: var(--display-pill-neutral-text-colour, var(--display-pill-text-colour, var(--slate-03)));
203
+ --_border-colour: var(--display-pill-neutral-border-colour, var(--display-pill-border-colour, transparent));
204
+ --_ring-colour: var(--display-pill-neutral-ring-colour, var(--display-pill-ring-colour, var(--_background)));
172
205
  }
173
206
  }
174
207
  }