srcdev-nuxt-components 9.6.13 → 9.6.15

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 (74) hide show
  1. package/.claude/rules/consumer/story-register-components.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +5 -0
  3. package/.claude/skills/components/display-qr-code.md +22 -4
  4. package/.claude/skills/components/eyebrow-text.md +9 -5
  5. package/.claude/skills/components/header-block.md +13 -5
  6. package/.claude/skills/components/hero-text.md +13 -9
  7. package/.claude/skills/components/link-text.md +8 -5
  8. package/.claude/skills/components/pop-over.md +17 -3
  9. package/.claude/skills/components/sliding-toggle.md +108 -0
  10. package/.claude/skills/components/tabbed-content.md +8 -3
  11. package/.claude/skills/composable-sliding-indicator.md +60 -0
  12. package/.claude/skills/index.md +2 -0
  13. package/.claude/skills/storybook-add-story.md +22 -2
  14. package/.claude/skills/testing-add-playwright.md +12 -7
  15. package/.claude/skills/testing-add-unit-test.md +25 -0
  16. package/.claude/skills/theming-component-token-pattern.md +18 -0
  17. package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
  18. package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
  19. package/.vscode/srcdev-component-link-text.code-snippets +3 -3
  20. package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
  21. package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
  22. package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
  23. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
  24. package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
  25. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
  26. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
  27. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
  28. package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
  29. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
  30. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
  31. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
  32. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
  33. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
  34. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
  35. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
  36. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
  37. package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
  38. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
  39. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
  40. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
  41. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
  42. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
  43. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
  44. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
  45. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
  46. package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
  47. package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
  48. package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
  49. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
  50. package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
  51. package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
  52. package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
  53. package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
  54. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
  55. package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
  56. package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
  57. package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
  58. package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
  59. package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
  60. package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
  61. package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
  62. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
  63. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
  64. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
  65. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
  66. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
  67. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
  68. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
  69. package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
  70. package/app/composables/useSlidingIndicator.ts +56 -0
  71. package/app/composables/useTabs.ts +4 -31
  72. package/app/types/components/index.ts +1 -0
  73. package/app/types/components/sliding-toggle.d.ts +5 -0
  74. package/package.json +1 -1
@@ -0,0 +1,335 @@
1
+ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { nextTick } from "vue";
4
+ import SlidingToggle from "../SlidingToggle.vue";
5
+ import type { SlidingToggleOption } from "~/types/components";
6
+
7
+ const billing: SlidingToggleOption[] = [
8
+ { value: "monthly", label: "Monthly" },
9
+ { value: "yearly", label: "Yearly" },
10
+ { value: "lifetime", label: "Lifetime" },
11
+ ];
12
+
13
+ const observed: Element[] = [];
14
+
15
+ class MockResizeObserver {
16
+ observe = vi.fn((element: Element) => observed.push(element));
17
+ unobserve = vi.fn();
18
+ disconnect = vi.fn(() => observed.splice(0));
19
+ }
20
+
21
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
22
+
23
+ const mountToggle = async (props: Record<string, unknown> = {}, attrs: Record<string, unknown> = {}) => {
24
+ const holder: { current?: Awaited<ReturnType<typeof mountSuspended>> } = {};
25
+ const onUpdate = (value: string | number) => {
26
+ void holder.current?.setProps({ modelValue: value });
27
+ };
28
+ const wrapper = await mountSuspended(SlidingToggle, {
29
+ props: { options: billing, modelValue: "monthly", ariaLabel: "Billing period", "onUpdate:modelValue": onUpdate, ...props },
30
+ attrs,
31
+ attachTo: document.body,
32
+ });
33
+ holder.current = wrapper;
34
+ mounted.push(wrapper);
35
+ return wrapper;
36
+ };
37
+
38
+ const radios = (wrapper: Awaited<ReturnType<typeof mountToggle>>) => wrapper.findAll('[role="radio"]');
39
+
40
+ const mockRow = () => {
41
+ vi.spyOn(HTMLElement.prototype, "offsetLeft", "get").mockImplementation(function (this: HTMLElement) {
42
+ return this.dataset.index === undefined ? 0 : Number(this.dataset.index) * 100;
43
+ });
44
+ vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) {
45
+ return this.dataset.index === undefined ? 0 : 100;
46
+ });
47
+ vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (this: HTMLElement) {
48
+ return this.classList.contains("sliding-toggle") ? 300 : 0;
49
+ });
50
+ };
51
+
52
+ const edge = (wrapper: Awaited<ReturnType<typeof mountToggle>>, name: string) =>
53
+ (wrapper.element as HTMLElement).style.getPropertyValue(`--_sliding-toggle-${name}`);
54
+
55
+ beforeEach(() => {
56
+ vi.stubGlobal("ResizeObserver", MockResizeObserver);
57
+ });
58
+
59
+ afterEach(() => {
60
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
61
+ vi.restoreAllMocks();
62
+ observed.splice(0);
63
+ });
64
+
65
+ describe("SlidingToggle", () => {
66
+ it("mounts without error", async () => {
67
+ const wrapper = await mountToggle();
68
+ expect(wrapper.vm).toBeTruthy();
69
+ });
70
+
71
+ it("renders correct HTML structure", async () => {
72
+ const wrapper = await mountToggle({ styleClassPassthrough: "custom-class" });
73
+ expect(wrapper.html()).toMatchSnapshot();
74
+ });
75
+
76
+ // ─── Semantics ───────────────────────────────────────────────────────────
77
+
78
+ it("renders a named radiogroup", async () => {
79
+ const wrapper = await mountToggle();
80
+ expect(wrapper.attributes("role")).toBe("radiogroup");
81
+ expect(wrapper.attributes("aria-label")).toBe("Billing period");
82
+ });
83
+
84
+ it("can be named by aria-labelledby instead", async () => {
85
+ const wrapper = await mountToggle({ ariaLabel: "", ariaLabelledby: "billing-heading" });
86
+ expect(wrapper.attributes("aria-label")).toBeUndefined();
87
+ expect(wrapper.attributes("aria-labelledby")).toBe("billing-heading");
88
+ });
89
+
90
+ it("renders one type=button radio per option, labelled by its text", async () => {
91
+ const wrapper = await mountToggle();
92
+ const options = radios(wrapper);
93
+ expect(options).toHaveLength(3);
94
+ options.forEach((option, index) => {
95
+ expect(option.element.tagName).toBe("BUTTON");
96
+ expect(option.attributes("type")).toBe("button");
97
+ expect(option.text()).toBe(billing[index]!.label);
98
+ });
99
+ });
100
+
101
+ it("marks the selected option aria-checked", async () => {
102
+ const wrapper = await mountToggle({ modelValue: "yearly" });
103
+ expect(radios(wrapper).map((option) => option.attributes("aria-checked"))).toEqual(["false", "true", "false"]);
104
+ });
105
+
106
+ it("hides both indicators from assistive tech", async () => {
107
+ const wrapper = await mountToggle();
108
+ wrapper.findAll(".sliding-toggle-indicator").forEach((indicator) => {
109
+ expect(indicator.attributes("aria-hidden")).toBe("true");
110
+ });
111
+ });
112
+
113
+ // ─── Selection ───────────────────────────────────────────────────────────
114
+
115
+ it("selects on click", async () => {
116
+ const wrapper = await mountToggle();
117
+ await radios(wrapper)[2]!.trigger("click");
118
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["lifetime"]);
119
+ expect(radios(wrapper)[2]!.attributes("aria-checked")).toBe("true");
120
+ });
121
+
122
+ it("follows an external v-model change", async () => {
123
+ const wrapper = await mountToggle();
124
+ await wrapper.setProps({ modelValue: "lifetime" });
125
+ expect(radios(wrapper)[2]!.attributes("aria-checked")).toBe("true");
126
+ expect(radios(wrapper)[0]!.attributes("aria-checked")).toBe("false");
127
+ });
128
+
129
+ it("supports numeric values", async () => {
130
+ const wrapper = await mountToggle({
131
+ options: [
132
+ { value: 1, label: "One" },
133
+ { value: 2, label: "Two" },
134
+ ],
135
+ modelValue: 1,
136
+ });
137
+ await radios(wrapper)[1]!.trigger("click");
138
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([2]);
139
+ });
140
+
141
+ // ─── Roving tabindex ─────────────────────────────────────────────────────
142
+
143
+ it("gives only the checked option the tab stop", async () => {
144
+ const wrapper = await mountToggle({ modelValue: "yearly" });
145
+ expect(radios(wrapper).map((option) => option.attributes("tabindex"))).toEqual(["-1", "0", "-1"]);
146
+ });
147
+
148
+ it("gives the first enabled option the tab stop when the value matches no option", async () => {
149
+ const wrapper = await mountToggle({
150
+ modelValue: "weekly",
151
+ options: [{ value: "a", label: "A", disabled: true }, ...billing],
152
+ });
153
+ expect(radios(wrapper).map((option) => option.attributes("tabindex"))).toEqual(["-1", "0", "-1", "-1"]);
154
+ expect(wrapper.attributes("data-has-selection")).toBeUndefined();
155
+ });
156
+
157
+ // ─── Keyboard ────────────────────────────────────────────────────────────
158
+
159
+ it("ArrowRight selects and focuses the next option", async () => {
160
+ const wrapper = await mountToggle();
161
+ await radios(wrapper)[0]!.trigger("keydown", { key: "ArrowRight" });
162
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["yearly"]);
163
+ expect(document.activeElement).toBe(radios(wrapper)[1]!.element);
164
+ });
165
+
166
+ it("ArrowDown also moves to the next option, and ArrowLeft/ArrowUp to the previous", async () => {
167
+ const wrapper = await mountToggle({ modelValue: "yearly" });
168
+ await radios(wrapper)[1]!.trigger("keydown", { key: "ArrowDown" });
169
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["lifetime"]);
170
+ await radios(wrapper)[2]!.trigger("keydown", { key: "ArrowUp" });
171
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["yearly"]);
172
+ await radios(wrapper)[1]!.trigger("keydown", { key: "ArrowLeft" });
173
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["monthly"]);
174
+ });
175
+
176
+ it("wraps from the last option to the first and back", async () => {
177
+ const wrapper = await mountToggle({ modelValue: "lifetime" });
178
+ await radios(wrapper)[2]!.trigger("keydown", { key: "ArrowRight" });
179
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["monthly"]);
180
+ await radios(wrapper)[0]!.trigger("keydown", { key: "ArrowLeft" });
181
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["lifetime"]);
182
+ });
183
+
184
+ it("Home and End select the first and last options", async () => {
185
+ const wrapper = await mountToggle({ modelValue: "yearly" });
186
+ await radios(wrapper)[1]!.trigger("keydown", { key: "End" });
187
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["lifetime"]);
188
+ await radios(wrapper)[2]!.trigger("keydown", { key: "Home" });
189
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["monthly"]);
190
+ });
191
+
192
+ it("skips disabled options", async () => {
193
+ const wrapper = await mountToggle({
194
+ options: [billing[0]!, { ...billing[1]!, disabled: true }, billing[2]!],
195
+ });
196
+ await radios(wrapper)[0]!.trigger("keydown", { key: "ArrowRight" });
197
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["lifetime"]);
198
+ });
199
+
200
+ it("RTL swaps the arrow directions", async () => {
201
+ const wrapper = await mountToggle({}, { dir: "rtl" });
202
+ await radios(wrapper)[0]!.trigger("keydown", { key: "ArrowLeft" });
203
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["yearly"]);
204
+ });
205
+
206
+ it("ignores other keys", async () => {
207
+ const wrapper = await mountToggle();
208
+ await radios(wrapper)[0]!.trigger("keydown", { key: "a" });
209
+ expect(wrapper.emitted("update:modelValue")).toBeUndefined();
210
+ });
211
+
212
+ // ─── Disabled ────────────────────────────────────────────────────────────
213
+
214
+ it("disables every option and blocks selection when the group is disabled", async () => {
215
+ const wrapper = await mountToggle({ disabled: true });
216
+ expect(wrapper.attributes("aria-disabled")).toBe("true");
217
+ radios(wrapper).forEach((option) => expect(option.attributes("disabled")).toBeDefined());
218
+ await radios(wrapper)[1]!.trigger("keydown", { key: "ArrowRight" });
219
+ expect(wrapper.emitted("update:modelValue")).toBeUndefined();
220
+ });
221
+
222
+ it("does not select a disabled option on click", async () => {
223
+ const wrapper = await mountToggle({ options: [billing[0]!, { ...billing[1]!, disabled: true }, billing[2]!] });
224
+ const vm = wrapper.vm as unknown as { select: (index: number) => void };
225
+ vm.select(1);
226
+ await nextTick();
227
+ expect(wrapper.emitted("update:modelValue")).toBeUndefined();
228
+ });
229
+
230
+ // ─── Indicators ──────────────────────────────────────────────────────────
231
+
232
+ it("sets data-ready once mounted and measured", async () => {
233
+ const wrapper = await mountToggle();
234
+ expect(wrapper.attributes("data-ready")).toBeDefined();
235
+ expect(wrapper.attributes("data-has-selection")).toBeDefined();
236
+ });
237
+
238
+ it("snaps the active indicator onto the checked option on mount", async () => {
239
+ mockRow();
240
+ const wrapper = await mountToggle({ modelValue: "yearly" });
241
+ expect(edge(wrapper, "active-start")).toBe("100px");
242
+ expect(edge(wrapper, "active-end")).toBe("100px");
243
+ expect(edge(wrapper, "active-duration")).toBe("0ms");
244
+ });
245
+
246
+ it("slides the active indicator with the leading edge first", async () => {
247
+ mockRow();
248
+ const wrapper = await mountToggle();
249
+ await radios(wrapper)[2]!.trigger("click");
250
+ await nextTick();
251
+ expect(edge(wrapper, "active-start")).toBe("200px");
252
+ expect(edge(wrapper, "active-start-delay")).toBe("200ms");
253
+ expect(edge(wrapper, "active-end-delay")).toBe("0ms");
254
+ });
255
+
256
+ it("moves the hover indicator with the pointer and back to the checked option on leave", async () => {
257
+ mockRow();
258
+ const wrapper = await mountToggle();
259
+ await radios(wrapper)[2]!.trigger("mouseenter");
260
+ expect(edge(wrapper, "hover-start")).toBe("200px");
261
+ expect(wrapper.attributes("data-hovering")).toBeDefined();
262
+ await wrapper.trigger("mouseleave");
263
+ expect(edge(wrapper, "hover-start")).toBe("0px");
264
+ expect(wrapper.attributes("data-hovering")).toBeUndefined();
265
+ });
266
+
267
+ it("does not track hover when trackHover is false", async () => {
268
+ mockRow();
269
+ const wrapper = await mountToggle({ trackHover: false });
270
+ await radios(wrapper)[2]!.trigger("mouseenter");
271
+ expect(edge(wrapper, "hover-start")).toBe("0px");
272
+ expect(wrapper.attributes("data-hovering")).toBeUndefined();
273
+ });
274
+
275
+ it("uses transitionDuration for the slide", async () => {
276
+ mockRow();
277
+ const wrapper = await mountToggle({ transitionDuration: 350 });
278
+ await radios(wrapper)[1]!.trigger("click");
279
+ await nextTick();
280
+ expect(edge(wrapper, "active-duration")).toBe("350ms");
281
+ });
282
+
283
+ // ─── Re-measuring ────────────────────────────────────────────────────────
284
+
285
+ it("observes the track and each option for size changes", async () => {
286
+ const wrapper = await mountToggle();
287
+ expect(observed).toContain(wrapper.element);
288
+ radios(wrapper).forEach((option) => expect(observed).toContain(option.element));
289
+ });
290
+
291
+ it("re-places the indicators when the options change", async () => {
292
+ mockRow();
293
+ const wrapper = await mountToggle({ modelValue: "lifetime" });
294
+ await wrapper.setProps({ options: [billing[2]!, billing[0]!] });
295
+ await nextTick();
296
+ await nextTick();
297
+ expect(edge(wrapper, "active-start")).toBe("0px");
298
+ });
299
+
300
+ // ─── Worst-case data ─────────────────────────────────────────────────────
301
+
302
+ it("renders an empty group without errors", async () => {
303
+ const wrapper = await mountToggle({ options: [] });
304
+ expect(radios(wrapper)).toHaveLength(0);
305
+ expect(wrapper.attributes("data-has-selection")).toBeUndefined();
306
+ });
307
+
308
+ it("renders duplicate labels as separate radios", async () => {
309
+ const wrapper = await mountToggle({
310
+ options: [
311
+ { value: "a", label: "Same" },
312
+ { value: "b", label: "Same" },
313
+ ],
314
+ modelValue: "b",
315
+ });
316
+ expect(radios(wrapper)).toHaveLength(2);
317
+ expect(radios(wrapper)[1]!.attributes("aria-checked")).toBe("true");
318
+ });
319
+
320
+ it("renders HTML-like labels as text", async () => {
321
+ const wrapper = await mountToggle({ options: [{ value: "x", label: "<b>not bold</b>" }], modelValue: "x" });
322
+ expect(wrapper.find("b").exists()).toBe(false);
323
+ expect(radios(wrapper)[0]!.text()).toBe("<b>not bold</b>");
324
+ });
325
+
326
+ // ─── styleClassPassthrough ───────────────────────────────────────────────
327
+
328
+ it("updates classes when styleClassPassthrough changes after mount", async () => {
329
+ const wrapper = await mountToggle({ styleClassPassthrough: ["original"] });
330
+ expect(wrapper.classes()).toContain("sliding-toggle");
331
+ await wrapper.setProps({ styleClassPassthrough: ["updated"] });
332
+ expect(wrapper.classes()).not.toContain("original");
333
+ expect(wrapper.classes()).toContain("updated");
334
+ });
335
+ });
@@ -0,0 +1,3 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`SlidingToggle > renders correct HTML structure 1`] = `"<div class="sliding-toggle custom-class" role="radiogroup" aria-label="Billing period" data-has-selection="" style="--_sliding-toggle-active-duration: 0ms; --_sliding-toggle-active-start-delay: 0ms; --_sliding-toggle-active-end-delay: 0ms; --_sliding-toggle-active-start: 0px; --_sliding-toggle-active-end: 0px; --_sliding-toggle-hover-duration: 0ms; --_sliding-toggle-hover-start-delay: 0ms; --_sliding-toggle-hover-end-delay: 0ms; --_sliding-toggle-hover-start: 0px; --_sliding-toggle-hover-end: 0px;" data-ready=""><span class="sliding-toggle-indicator sliding-toggle-indicator-hover" aria-hidden="true"></span><span class="sliding-toggle-indicator sliding-toggle-indicator-active" aria-hidden="true"></span><button type="button" role="radio" class="sliding-toggle-option" data-index="0" aria-checked="true" tabindex="0">Monthly</button><button type="button" role="radio" class="sliding-toggle-option" data-index="1" aria-checked="false" tabindex="-1">Yearly</button><button type="button" role="radio" class="sliding-toggle-option" data-index="2" aria-checked="false" tabindex="-1">Lifetime</button></div>"`;
@@ -17,9 +17,9 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
17
17
  </li>
18
18
  </ol>
19
19
  </nav>
20
- <div class="eyebrow-text large">Freehand colour artistry</div>
21
- <h1 id="v-0-0" class="hero-text display axis-horizontal">
22
- <!--v-if--><span class="text-block-0 normal">Balayage</span>
20
+ <div class="eyebrow-text" data-font-size="large">Freehand colour artistry</div>
21
+ <h1 id="v-0-0" class="hero-text" data-font-size="display" data-axis="horizontal">
22
+ <!--v-if--><span class="hero-text-segment normal">Balayage</span>
23
23
  </h1>
24
24
  <div class="service-detail__hero-pills"><span class="display-pill" data-size="md" data-variant="neutral"><!--v-if--><span class="display-pill-label">2.5 - 3.5 hours</span></span><span class="display-pill" data-size="md" data-variant="neutral"><!--v-if--><span class="display-pill-label">From £95</span></span></div>
25
25
  </div>
@@ -30,12 +30,12 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
30
30
  <div class="service-detail__body">
31
31
  <div class="service-detail__main">
32
32
  <p class="page-body-normal">Balayage is a freehand hair colouring technique that creates a soft gradient.</p>
33
- <h2 class="hero-text mb-20 subheading axis-horizontal">
34
- <!--v-if--><span class="text-block-0 normal">What is Balayage?</span>
33
+ <h2 class="hero-text mb-20" data-font-size="subheading" data-axis="horizontal">
34
+ <!--v-if--><span class="hero-text-segment normal">What is Balayage?</span>
35
35
  </h2>
36
36
  <p class="page-body-normal">The word balayage comes from the French for 'to sweep'.</p>
37
- <h2 class="hero-text mb-20 subheading axis-horizontal">
38
- <!--v-if--><span class="text-block-0 normal">The Process</span>
37
+ <h2 class="hero-text mb-20" data-font-size="subheading" data-axis="horizontal">
38
+ <!--v-if--><span class="hero-text-segment normal">The Process</span>
39
39
  </h2>
40
40
  <ol role="list" class="stepper-list has-connectors service-detail__process">
41
41
  <li class="stepper-list-item indicator-top indicator-circle" style="--_connector-left: 0px; --_connector-top: 0px; --_connector-height: 0px;">
@@ -57,8 +57,8 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
57
57
  </div>
58
58
  </li>
59
59
  </ol>
60
- <h2 class="hero-text mb-20 subheading axis-horizontal">
61
- <!--v-if--><span class="text-block-0 normal">Ideal For</span>
60
+ <h2 class="hero-text mb-20" data-font-size="subheading" data-axis="horizontal">
61
+ <!--v-if--><span class="hero-text-segment normal">Ideal For</span>
62
62
  </h2>
63
63
  <ul class="service-detail__ideal-for">
64
64
  <li class="service-detail__ideal-for-item"><span class="iconify i-mdi:diamond-stone service-detail__ideal-for-icon" aria-hidden="true"></span>
@@ -68,12 +68,12 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
68
68
  <p class="page-body-normal">Low-maintenance colour</p>
69
69
  </li>
70
70
  </ul>
71
- <h2 class="hero-text mb-20 subheading axis-horizontal">
72
- <!--v-if--><span class="text-block-0 normal">Aftercare &amp; Maintenance</span>
71
+ <h2 class="hero-text mb-20" data-font-size="subheading" data-axis="horizontal">
72
+ <!--v-if--><span class="hero-text-segment normal">Aftercare &amp; Maintenance</span>
73
73
  </h2>
74
74
  <p class="page-body-normal">Balayage is low maintenance since regrowth is soft and natural.</p>
75
- <h2 class="hero-text mb-20 subheading axis-horizontal">
76
- <!--v-if--><span class="text-block-0 normal">Frequently Asked Questions</span>
75
+ <h2 class="hero-text mb-20" data-font-size="subheading" data-axis="horizontal">
76
+ <!--v-if--><span class="hero-text-segment normal">Frequently Asked Questions</span>
77
77
  </h2>
78
78
  <div class="service-detail__faqs">
79
79
  <div class="service-detail__faq">
@@ -98,8 +98,8 @@ exports[`ServiceDetail > renders correct HTML structure 1`] = `
98
98
  <div class="page-row content service-detail__final-cta-row">
99
99
  <div class="service-detail__final-cta">
100
100
  <div class="service-detail__final-cta-copy">
101
- <h2 class="hero-text subheading axis-horizontal">
102
- <!--v-if--><span class="text-block-0 normal">Ready to book your appointment?</span>
101
+ <h2 class="hero-text" data-font-size="subheading" data-axis="horizontal">
102
+ <!--v-if--><span class="hero-text-segment normal">Ready to book your appointment?</span>
103
103
  </h2>
104
104
  <p class="page-body-normal">Get in touch to book your appointment.</p>
105
105
  </div>
@@ -5,9 +5,9 @@ exports[`ServiceSummary > renders correct HTML structure 1`] = `
5
5
  <div class="service-summary__grid">
6
6
  <div class="service-summary__image-wrapper"><img width="1080" height="1080" data-nuxt-img="" srcset="/_ipx/s_1080x1080/images/test.jpg 1x, /_ipx/s_2160x2160/images/test.jpg 2x" alt="Test Service" loading="eager" fetchpriority="high" class="service-summary__image" src="/_ipx/s_1080x1080/images/test.jpg"></div>
7
7
  <div class="service-summary__info-wrapper service-summary__info-wrapper--align-center">
8
- <div class="eyebrow-text large">A subtitle</div>
9
- <h2 id="v-0-0" class="hero-text mb-20 title axis-horizontal">
10
- <!--v-if--><span class="text-block-0 normal">Test Service</span>
8
+ <div class="eyebrow-text" data-font-size="large">A subtitle</div>
9
+ <h2 id="v-0-0" class="hero-text mb-20" data-font-size="title" data-axis="horizontal">
10
+ <!--v-if--><span class="hero-text-segment normal">Test Service</span>
11
11
  </h2>
12
12
  <div class="service-summary__pills"><span class="display-pill" data-size="md" data-variant="neutral"><!--v-if--><span class="display-pill-label">60 mins</span></span><span class="display-pill" data-size="md" data-variant="neutral"><!--v-if--><span class="display-pill-label">From £50</span></span></div>
13
13
  <p class="page-body-normal">What this service is</p>
@@ -217,14 +217,14 @@ describe("ServicesCard", () => {
217
217
  const wrapper = await mountSuspended(ServicesCard, {
218
218
  props: { serviceData: mockService },
219
219
  });
220
- expect(wrapper.find(".eyebrow-text").classes()).toContain("large");
220
+ expect(wrapper.find(".eyebrow-text").attributes("data-font-size")).toBe("large");
221
221
  });
222
222
 
223
223
  it("applies eyebrowConfig.fontSize override", async () => {
224
224
  const wrapper = await mountSuspended(ServicesCard, {
225
225
  props: { serviceData: mockService, eyebrowConfig: { fontSize: "small" } },
226
226
  });
227
- expect(wrapper.find(".eyebrow-text").classes()).toContain("small");
227
+ expect(wrapper.find(".eyebrow-text").attributes("data-font-size")).toBe("small");
228
228
  });
229
229
 
230
230
  it("applies eyebrowConfig.tag override", async () => {
@@ -254,7 +254,7 @@ describe("ServicesCard", () => {
254
254
  const wrapper = await mountSuspended(ServicesCard, {
255
255
  props: { serviceData: mockService, heroConfig: { fontSize: "title" } },
256
256
  });
257
- expect(wrapper.find(".hero-text").classes()).toContain("title");
257
+ expect(wrapper.find(".hero-text").attributes("data-font-size")).toBe("title");
258
258
  });
259
259
 
260
260
  // ─── styleClassPassthrough ──────────────────────────────────────────────
@@ -7,9 +7,9 @@ exports[`ServicesCard > renders correct HTML structure 1`] = `
7
7
  <div class="grid-stack__layer"></div>
8
8
  </div>
9
9
  <div class="services-card-details">
10
- <div class="eyebrow-text large">A subtitle</div>
11
- <h2 class="hero-text heading axis-horizontal">
12
- <!--v-if--><span class="text-block-0 normal">Test Service</span>
10
+ <div class="eyebrow-text" data-font-size="large">A subtitle</div>
11
+ <h2 class="hero-text" data-font-size="heading" data-axis="horizontal">
12
+ <!--v-if--><span class="hero-text-segment normal">Test Service</span>
13
13
  </h2>
14
14
  <div class="services-card-description">A short description of the service.</div>
15
15
  <div class="services-card-footer">
@@ -139,7 +139,7 @@ describe("ServicesCardGrid", () => {
139
139
  props: { servicesData: mockServices, eyebrowConfig: { fontSize: "small" } },
140
140
  });
141
141
  wrapper.findAll(".eyebrow-text").forEach((el) => {
142
- expect(el.classes()).toContain("small");
142
+ expect(el.attributes("data-font-size")).toBe("small");
143
143
  });
144
144
  });
145
145
 
@@ -6,9 +6,9 @@ exports[`ServiceSummaryGrid > renders correct HTML structure 1`] = `
6
6
  <div class="service-summary__grid">
7
7
  <div class="service-summary__image-wrapper"><img width="1080" height="1080" data-nuxt-img="" srcset="/_ipx/s_1080x1080/images/locs-installation.jpg 1x, /_ipx/s_2160x2160/images/locs-installation.jpg 2x" alt="Locs Installation" loading="eager" fetchpriority="high" class="service-summary__image" src="/_ipx/s_1080x1080/images/locs-installation.jpg"></div>
8
8
  <div class="service-summary__info-wrapper service-summary__info-wrapper--align-center">
9
- <div class="eyebrow-text large">Subtitle for Locs Installation</div>
10
- <h2 id="v-0-0-0" class="hero-text mb-20 title axis-horizontal">
11
- <!--v-if--><span class="text-block-0 normal">Locs Installation</span>
9
+ <div class="eyebrow-text" data-font-size="large">Subtitle for Locs Installation</div>
10
+ <h2 id="v-0-0-0" class="hero-text mb-20" data-font-size="title" data-axis="horizontal">
11
+ <!--v-if--><span class="hero-text-segment normal">Locs Installation</span>
12
12
  </h2>
13
13
  <div class="service-summary__pills"><span class="display-pill" data-size="md" data-variant="neutral"><!--v-if--><span class="display-pill-label">60 mins</span></span><span class="display-pill" data-size="md" data-variant="neutral"><!--v-if--><span class="display-pill-label">From £50</span></span></div>
14
14
  <p class="page-body-normal">What this service is.</p>
@@ -19,9 +19,9 @@ exports[`ServiceSummaryGrid > renders correct HTML structure 1`] = `
19
19
  <div class="service-summary__grid">
20
20
  <div class="service-summary__image-wrapper"><img width="1080" height="1080" data-nuxt-img="" srcset="/_ipx/s_1080x1080/images/locs-retwist.jpg 1x, /_ipx/s_2160x2160/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="eager" fetchpriority="auto" class="service-summary__image" src="/_ipx/s_1080x1080/images/locs-retwist.jpg"></div>
21
21
  <div class="service-summary__info-wrapper service-summary__info-wrapper--align-center">
22
- <div class="eyebrow-text large">Subtitle for Locs Retwist</div>
23
- <h2 id="v-0-0-1" class="hero-text mb-20 title axis-horizontal">
24
- <!--v-if--><span class="text-block-0 normal">Locs Retwist</span>
22
+ <div class="eyebrow-text" data-font-size="large">Subtitle for Locs Retwist</div>
23
+ <h2 id="v-0-0-1" class="hero-text mb-20" data-font-size="title" data-axis="horizontal">
24
+ <!--v-if--><span class="hero-text-segment normal">Locs Retwist</span>
25
25
  </h2>
26
26
  <div class="service-summary__pills"><span class="display-pill" data-size="md" data-variant="neutral"><!--v-if--><span class="display-pill-label">60 mins</span></span><span class="display-pill" data-size="md" data-variant="neutral"><!--v-if--><span class="display-pill-label">From £50</span></span></div>
27
27
  <p class="page-body-normal">What this service is.</p>
@@ -32,9 +32,9 @@ exports[`ServiceSummaryGrid > renders correct HTML structure 1`] = `
32
32
  <div class="service-summary__grid">
33
33
  <div class="service-summary__image-wrapper"><img width="1080" height="1080" data-nuxt-img="" srcset="/_ipx/s_1080x1080/images/colour-treatment.jpg 1x, /_ipx/s_2160x2160/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" fetchpriority="auto" class="service-summary__image" src="/_ipx/s_1080x1080/images/colour-treatment.jpg"></div>
34
34
  <div class="service-summary__info-wrapper service-summary__info-wrapper--align-center">
35
- <div class="eyebrow-text large">Subtitle for Colour Treatment</div>
36
- <h2 id="v-0-0-2" class="hero-text mb-20 title axis-horizontal">
37
- <!--v-if--><span class="text-block-0 normal">Colour Treatment</span>
35
+ <div class="eyebrow-text" data-font-size="large">Subtitle for Colour Treatment</div>
36
+ <h2 id="v-0-0-2" class="hero-text mb-20" data-font-size="title" data-axis="horizontal">
37
+ <!--v-if--><span class="hero-text-segment normal">Colour Treatment</span>
38
38
  </h2>
39
39
  <div class="service-summary__pills"><span class="display-pill" data-size="md" data-variant="neutral"><!--v-if--><span class="display-pill-label">60 mins</span></span><span class="display-pill" data-size="md" data-variant="neutral"><!--v-if--><span class="display-pill-label">From £50</span></span></div>
40
40
  <p class="page-body-normal">What this service is.</p>
@@ -8,9 +8,9 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
8
8
  <div class="grid-stack__layer"></div>
9
9
  </div>
10
10
  <div class="services-card-details">
11
- <div class="eyebrow-text large">Subtitle for Locs Installation</div>
12
- <h2 class="hero-text heading axis-horizontal">
13
- <!--v-if--><span class="text-block-0 normal">Locs Installation</span>
11
+ <div class="eyebrow-text" data-font-size="large">Subtitle for Locs Installation</div>
12
+ <h2 class="hero-text" data-font-size="heading" data-axis="horizontal">
13
+ <!--v-if--><span class="hero-text-segment normal">Locs Installation</span>
14
14
  </h2>
15
15
  <div class="services-card-description">Short description for Locs Installation.</div>
16
16
  <div class="services-card-footer">
@@ -31,9 +31,9 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
31
31
  <div class="grid-stack__layer"></div>
32
32
  </div>
33
33
  <div class="services-card-details">
34
- <div class="eyebrow-text large">Subtitle for Locs Retwist</div>
35
- <h2 class="hero-text heading axis-horizontal">
36
- <!--v-if--><span class="text-block-0 normal">Locs Retwist</span>
34
+ <div class="eyebrow-text" data-font-size="large">Subtitle for Locs Retwist</div>
35
+ <h2 class="hero-text" data-font-size="heading" data-axis="horizontal">
36
+ <!--v-if--><span class="hero-text-segment normal">Locs Retwist</span>
37
37
  </h2>
38
38
  <div class="services-card-description">Short description for Locs Retwist.</div>
39
39
  <div class="services-card-footer">
@@ -54,9 +54,9 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
54
54
  <div class="grid-stack__layer"></div>
55
55
  </div>
56
56
  <div class="services-card-details">
57
- <div class="eyebrow-text large">Subtitle for Colour Treatment</div>
58
- <h2 class="hero-text heading axis-horizontal">
59
- <!--v-if--><span class="text-block-0 normal">Colour Treatment</span>
57
+ <div class="eyebrow-text" data-font-size="large">Subtitle for Colour Treatment</div>
58
+ <h2 class="hero-text" data-font-size="heading" data-axis="horizontal">
59
+ <!--v-if--><span class="hero-text-segment normal">Colour Treatment</span>
60
60
  </h2>
61
61
  <div class="services-card-description">Short description for Colour Treatment.</div>
62
62
  <div class="services-card-footer">