srcdev-nuxt-components 9.3.7 → 9.3.8

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 (108) hide show
  1. package/.claude/hooks/check-component-update.sh +30 -1
  2. package/.claude/skills/components/breadcrumb.md +49 -0
  3. package/.claude/skills/components/content-docs.md +1 -1
  4. package/.claude/skills/components/service-detail.md +156 -0
  5. package/.claude/skills/components/services-card-grid.md +9 -9
  6. package/.claude/skills/components/services-card.md +82 -30
  7. package/.claude/skills/index.md +3 -1
  8. package/.claude/skills/qa-panel.md +2 -2
  9. package/.vscode/srcdev-component-breadcrumb.code-snippets +14 -0
  10. package/.vscode/srcdev-component-service-detail.code-snippets +49 -0
  11. package/.vscode/srcdev-component-services-card.code-snippets +108 -0
  12. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +94 -0
  13. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +32 -0
  14. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +87 -0
  15. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +87 -0
  16. package/app/components/01.atoms/navigation/breadcrumb/tests/__snapshots__/Breadcrumb.spec.ts.snap +13 -0
  17. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +181 -0
  18. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +480 -0
  19. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +282 -0
  20. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +270 -0
  21. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +99 -0
  22. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +129 -0
  23. package/app/components/03.organisms/services/services-card/ServicesCard.vue +153 -39
  24. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +65 -0
  25. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +82 -0
  26. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +13 -5
  27. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +3 -7
  28. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +126 -2
  29. package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +6 -0
  30. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +51 -27
  31. package/app/types/components/breadcrumb.ts +4 -0
  32. package/app/types/components/index.ts +1 -0
  33. package/package.json +2 -2
  34. package/app/pages/auto-grid.vue +0 -328
  35. package/app/pages/banner-video.vue +0 -238
  36. package/app/pages/forms/examples/buttons/index.vue +0 -259
  37. package/app/pages/forms/examples/material/checkbox-radio-panels.vue +0 -244
  38. package/app/pages/forms/examples/material/cssbattle.vue +0 -60
  39. package/app/pages/forms/examples/material/text-fields.vue +0 -910
  40. package/app/pages/grid-stack.vue +0 -252
  41. package/app/pages/index.vue +0 -127
  42. package/app/pages/page-hero-highlights.vue +0 -204
  43. package/app/pages/samaritan.vue +0 -327
  44. package/app/pages/typography/hero-text.vue +0 -97
  45. package/app/pages/typography/index.vue +0 -39
  46. package/app/pages/typography/page-body.vue +0 -171
  47. package/app/pages/typography/page-heading.vue +0 -76
  48. package/app/pages/typography/page-link.vue +0 -103
  49. package/app/pages/ui/accordian.vue +0 -211
  50. package/app/pages/ui/anchor-scroll-tab-navigation.vue +0 -191
  51. package/app/pages/ui/anchor-scroll.vue +0 -367
  52. package/app/pages/ui/animated-svg-text.vue +0 -85
  53. package/app/pages/ui/block-decorators.vue +0 -142
  54. package/app/pages/ui/card-core.vue +0 -82
  55. package/app/pages/ui/carousel-basic.vue +0 -292
  56. package/app/pages/ui/carousel-flip.vue +0 -449
  57. package/app/pages/ui/carousel-infinite.vue +0 -260
  58. package/app/pages/ui/clipped-panels.vue +0 -101
  59. package/app/pages/ui/contact-section.vue +0 -323
  60. package/app/pages/ui/container-glow.vue +0 -106
  61. package/app/pages/ui/content-width.vue +0 -126
  62. package/app/pages/ui/deep-expanding-menu.vue +0 -138
  63. package/app/pages/ui/display-avatar.vue +0 -253
  64. package/app/pages/ui/display-banner.vue +0 -78
  65. package/app/pages/ui/display-chip.vue +0 -367
  66. package/app/pages/ui/display-details.vue +0 -171
  67. package/app/pages/ui/display-dialog.vue +0 -344
  68. package/app/pages/ui/display-pill.vue +0 -396
  69. package/app/pages/ui/display-prompt.vue +0 -284
  70. package/app/pages/ui/display-toast.vue +0 -311
  71. package/app/pages/ui/expanding-panel.vue +0 -326
  72. package/app/pages/ui/glowing-border.vue +0 -111
  73. package/app/pages/ui/layout-content-docs.vue +0 -72
  74. package/app/pages/ui/layout-grid-a.vue +0 -96
  75. package/app/pages/ui/layout-grid-b.vue +0 -132
  76. package/app/pages/ui/magnetic-navigation.vue +0 -50
  77. package/app/pages/ui/marquee-scroller.vue +0 -99
  78. package/app/pages/ui/mask-element.vue +0 -104
  79. package/app/pages/ui/masonry-columns.vue +0 -55
  80. package/app/pages/ui/masonry-grid-ordered-experiment.vue +0 -79
  81. package/app/pages/ui/masonry-grid-ordered.vue +0 -56
  82. package/app/pages/ui/masonry-grid-sorted.vue +0 -55
  83. package/app/pages/ui/masonry-grid.vue +0 -55
  84. package/app/pages/ui/navigation/navigation-horizontal.vue +0 -484
  85. package/app/pages/ui/navigation/site-navigation/about.vue +0 -36
  86. package/app/pages/ui/navigation/site-navigation/contact.vue +0 -39
  87. package/app/pages/ui/navigation/site-navigation/index.vue +0 -38
  88. package/app/pages/ui/navigation/site-navigation/portfolio.vue +0 -43
  89. package/app/pages/ui/navigation/site-navigation/services.vue +0 -42
  90. package/app/pages/ui/navigation/tab-navigation.vue +0 -493
  91. package/app/pages/ui/page-row.vue +0 -127
  92. package/app/pages/ui/price-list.vue +0 -65
  93. package/app/pages/ui/profile-section.vue +0 -142
  94. package/app/pages/ui/qr-code/[componentName].vue +0 -218
  95. package/app/pages/ui/rotating-carousel.vue +0 -154
  96. package/app/pages/ui/scroll-reveal-image.vue +0 -483
  97. package/app/pages/ui/section-parallax.vue +0 -54
  98. package/app/pages/ui/services/colour-finder.vue +0 -43
  99. package/app/pages/ui/services/services-cards.vue +0 -57
  100. package/app/pages/ui/services/services-section/[slug].vue +0 -61
  101. package/app/pages/ui/services/services-sections-compact.vue +0 -64
  102. package/app/pages/ui/settings.vue +0 -54
  103. package/app/pages/ui/simple-grid.vue +0 -55
  104. package/app/pages/ui/slider-gallery.vue +0 -261
  105. package/app/pages/ui/tabs-y.vue +0 -107
  106. package/app/pages/ui/tabs.vue +0 -189
  107. package/app/pages/ui/tooltips.vue +0 -104
  108. package/app/pages/ui/wipe-away-vertical.vue +0 -129
@@ -0,0 +1,108 @@
1
+ {
2
+ "SRCDEV ServicesCard Basic": {
3
+ "description": "ServicesCard basic usage with actions slot",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<ServicesCard :service-data=\"$1service\">",
7
+ " <template #actions=\"{ serviceData }\">",
8
+ " <InputButtonCore",
9
+ " variant=\"secondary\"",
10
+ " :button-text=\"`More about ${serviceData.title}`\"",
11
+ " :href=\"`/services/${serviceData.slug}`\"",
12
+ " >",
13
+ " <template #right>",
14
+ " <Icon name=\"mdi:arrow-right\" class=\"icon\" />",
15
+ " </template>",
16
+ " </InputButtonCore>",
17
+ " </template>",
18
+ "</ServicesCard>"
19
+ ]
20
+ },
21
+ "SRCDEV ServicesCard Whole Card Clickable": {
22
+ "description": "ServicesCard with href and no actions slot — whole card is clickable",
23
+ "scope": "vue,html",
24
+ "body": [
25
+ "<ServicesCard :service-data=\"$1service\" :href=\"`/services/${$1service.slug}`\" />"
26
+ ]
27
+ },
28
+ "SRCDEV ServicesCard With Config Overrides": {
29
+ "description": "ServicesCard with eyebrowConfig/heroConfig overrides",
30
+ "scope": "vue,html",
31
+ "body": [
32
+ "<ServicesCard",
33
+ " :service-data=\"$1service\"",
34
+ " :eyebrow-config=\"{ fontSize: '$2small' }\"",
35
+ " :hero-config=\"{ tag: '$3h3', fontSize: '$4title' }\"",
36
+ ">",
37
+ " <template #actions=\"{ serviceData }\">",
38
+ " <InputButtonCore variant=\"secondary\" button-text=\"$5Enquire\" :href=\"`/services/${serviceData.slug}`\" />",
39
+ " </template>",
40
+ "</ServicesCard>"
41
+ ]
42
+ },
43
+ "SRCDEV ServicesCard With Custom Meta Row": {
44
+ "description": "ServicesCard with custom duration/price slot content (e.g. icons)",
45
+ "scope": "vue,html",
46
+ "body": [
47
+ "<ServicesCard :service-data=\"$1service\">",
48
+ " <template #duration=\"{ serviceData }\">",
49
+ " <Icon name=\"mdi:clock-time-four-outline\" class=\"icon\" />",
50
+ " <span>{{ serviceData.duration }}</span>",
51
+ " </template>",
52
+ " <template #price=\"{ serviceData }\">",
53
+ " <Icon name=\"mdi:currency-gbp\" class=\"icon\" />",
54
+ " <span>From {{ serviceData.price }}</span>",
55
+ " </template>",
56
+ " <template #actions=\"{ serviceData }\">",
57
+ " <InputButtonCore variant=\"secondary\" button-text=\"$2Enquire\" :href=\"`/services/${serviceData.slug}`\" />",
58
+ " </template>",
59
+ "</ServicesCard>"
60
+ ]
61
+ },
62
+ "SRCDEV ServicesCard CSS Override": {
63
+ "description": "CSS override scaffold for ServicesCard — scope to a page or section class. See CONSUMER-STYLING.md for the full token list.",
64
+ "scope": "css",
65
+ "body": [
66
+ ".$1my-page {",
67
+ " .services-card {",
68
+ " /* Public tokens — could also be set globally on :root instead */",
69
+ " /* Interaction states — only visible in whole-card-clickable mode (is-clickable) */",
70
+ " --services-card-border-radius: ;",
71
+ " --services-card-border-width: ;",
72
+ " --services-card-border-colour: ;",
73
+ " --services-card-border-colour-hover: ;",
74
+ " --services-card-outline-width: ;",
75
+ " --services-card-outline-colour: ;",
76
+ " --services-card-outline-colour-hover: ;",
77
+ " --services-card-outline-offset: ;",
78
+ " --services-card-outline-offset-hover: ;",
79
+ "",
80
+ " --services-card-gap: ;",
81
+ " --image-wrapper-aspect-ratio: ;",
82
+ " --image-wrapper-border-radius: ;",
83
+ " --image-wrapper-padding-block: ;",
84
+ " --image-wrapper-padding-inline: ;",
85
+ " --image-wrapper-border-image-zoom-transform: ;",
86
+ " --details-wrapper-grid-gap: ;",
87
+ " --details-wrapper-padding-block: ;",
88
+ " --details-wrapper-padding-inline: ;",
89
+ " --description-padding-block: ;",
90
+ " --description-line-height: ;",
91
+ " --description-line-clamp: ;",
92
+ " --meta-padding-block: ;",
93
+ " --meta-text-transform: ;",
94
+ " --footer-padding-block: ;",
95
+ " --footer-wrapper-grid-gap: ;",
96
+ "",
97
+ " /* Local-only tokens — page-scoped override only, no :root fallback */",
98
+ " --_eyebrow-text-padding-block: ;",
99
+ " --_hero-text-padding-block: ;",
100
+ " --_description-text-colour: ;",
101
+ " --_meta-border-colour: ;",
102
+ " --_meta-text-colour: ;",
103
+ " --_meta-font-size: ;",
104
+ " }",
105
+ "}"
106
+ ]
107
+ }
108
+ }
@@ -0,0 +1,94 @@
1
+ <template>
2
+ <nav class="breadcrumb" :class="[elementClasses]" aria-label="Breadcrumb">
3
+ <ol class="breadcrumb__list">
4
+ <li v-for="(item, index) in items" :key="`${item.label}-${index}`" class="breadcrumb__item">
5
+ <NuxtLink v-if="item.to" :to="item.to" class="breadcrumb__link">{{ item.label }}</NuxtLink>
6
+ <span v-else class="breadcrumb__label" :aria-current="index === items.length - 1 ? 'page' : undefined">{{
7
+ item.label
8
+ }}</span>
9
+ <span v-if="index < items.length - 1" class="breadcrumb__separator" aria-hidden="true">{{ separator }}</span>
10
+ </li>
11
+ </ol>
12
+ </nav>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ import type { BreadcrumbItem } from "~/types/components/breadcrumb";
17
+
18
+ interface Props {
19
+ items: BreadcrumbItem[];
20
+ separator?: string;
21
+ styleClassPassthrough?: string | string[];
22
+ }
23
+
24
+ const props = withDefaults(defineProps<Props>(), {
25
+ separator: "/",
26
+ styleClassPassthrough: () => [],
27
+ });
28
+
29
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
30
+
31
+ watch(
32
+ () => props.styleClassPassthrough,
33
+ () => {
34
+ resetElementClasses(props.styleClassPassthrough);
35
+ }
36
+ );
37
+ </script>
38
+
39
+ <style lang="css">
40
+ @layer components {
41
+ .breadcrumb {
42
+ --_font-size: var(--breadcrumb-font-size, 1.3rem);
43
+ --_gap: var(--breadcrumb-gap, 0.8rem);
44
+ --_text-transform: var(--breadcrumb-text-transform, uppercase);
45
+ --_letter-spacing: var(--breadcrumb-letter-spacing, 0.05em);
46
+ --_colour: var(--breadcrumb-colour, currentColor);
47
+ --_colour-current: var(--breadcrumb-colour-current, var(--breadcrumb-colour, currentColor));
48
+ --_link-decoration-hover: var(--breadcrumb-link-decoration-hover, underline);
49
+
50
+ font-size: var(--_font-size);
51
+ text-transform: var(--_text-transform);
52
+ letter-spacing: var(--_letter-spacing);
53
+
54
+ .breadcrumb__list {
55
+ display: flex;
56
+ flex-wrap: wrap;
57
+ align-items: center;
58
+ gap: var(--_gap);
59
+ margin: 0;
60
+ padding: 0;
61
+ list-style: none;
62
+ }
63
+
64
+ .breadcrumb__item {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: var(--_gap);
68
+ }
69
+
70
+ .breadcrumb__link,
71
+ .breadcrumb__label {
72
+ color: var(--_colour);
73
+ }
74
+
75
+ .breadcrumb__label[aria-current="page"] {
76
+ color: var(--_colour-current);
77
+ }
78
+
79
+ .breadcrumb__link {
80
+ text-decoration: none;
81
+
82
+ &:hover,
83
+ &:focus-visible {
84
+ text-decoration: var(--_link-decoration-hover);
85
+ }
86
+ }
87
+
88
+ .breadcrumb__separator {
89
+ color: var(--_colour);
90
+ opacity: 0.6;
91
+ }
92
+ }
93
+ }
94
+ </style>
@@ -0,0 +1,32 @@
1
+ # Breadcrumb — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ | Token | Default | Controls |
6
+ |---|---|---|
7
+ | `--breadcrumb-font-size` | `1.3rem` | Font size of the whole trail |
8
+ | `--breadcrumb-gap` | `0.8rem` | Gap between an item's label and its separator, and between items |
9
+ | `--breadcrumb-text-transform` | `uppercase` | Text transform of the whole trail |
10
+ | `--breadcrumb-letter-spacing` | `0.05em` | Letter spacing of the whole trail |
11
+ | `--breadcrumb-colour` | `currentColor` | Colour of links, separators, and non-current labels |
12
+ | `--breadcrumb-colour-current` | `currentColor` | Colour of the current-page item (the one without a `to`) |
13
+ | `--breadcrumb-link-decoration-hover` | `underline` | `text-decoration` on link hover/focus |
14
+
15
+ ```css
16
+ .breadcrumb {
17
+ --breadcrumb-colour: white;
18
+ --breadcrumb-colour-current: var(--colour-text-accent);
19
+ }
20
+ ```
21
+
22
+ ## Text content — props, not CSS
23
+
24
+ `items` and `separator` are props. There is no hardcoded copy to override in CSS.
25
+
26
+ ## Notes
27
+
28
+ - An item renders as a `NuxtLink` when it has a `to`, otherwise as `<span aria-current="page">` —
29
+ omit `to` on whichever item represents the current page (usually the last one).
30
+ - The separator (`aria-hidden="true"`) is only rendered between items, never after the last one.
31
+ - `--breadcrumb-colour` is what `ServiceDetail` overrides internally (to `white` by default) so
32
+ the breadcrumb reads over its hero image — see `ServiceDetail`'s `CONSUMER-STYLING.md`.
@@ -0,0 +1,87 @@
1
+ import Breadcrumb from "../Breadcrumb.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import type { BreadcrumbItem } from "~/types/components/breadcrumb";
4
+
5
+ const meta: Meta<typeof Breadcrumb> = {
6
+ title: "Atoms/Navigation/Breadcrumb",
7
+ component: Breadcrumb,
8
+ argTypes: {
9
+ items: {
10
+ control: "object",
11
+ description: "Ordered list of { label, to? } — items without `to` render as plain text (typically the last, current-page item)",
12
+ },
13
+ separator: {
14
+ control: "text",
15
+ description: "Character or short string rendered between items",
16
+ },
17
+ styleClassPassthrough: {
18
+ control: "object",
19
+ description: "Additional CSS classes applied to the root element",
20
+ },
21
+ },
22
+ args: {
23
+ separator: "/",
24
+ styleClassPassthrough: [],
25
+ },
26
+ parameters: {
27
+ docs: {
28
+ description: {
29
+ component:
30
+ "Renders a `<nav aria-label=\"Breadcrumb\">` trail from an items array. Any item with a `to` renders as a NuxtLink; an item without one renders as plain text marked `aria-current=\"page\"` — routing decisions stay with the consumer, same as the rest of this library.",
31
+ },
32
+ },
33
+ },
34
+ };
35
+
36
+ export default meta;
37
+ type Story = StoryObj<typeof Breadcrumb>;
38
+
39
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
40
+
41
+ const items: BreadcrumbItem[] = [
42
+ { label: "Services", to: "/services" },
43
+ { label: "Balayage" },
44
+ ];
45
+
46
+ // ─── Stories ──────────────────────────────────────────────────────────────────
47
+
48
+ export const Default: Story = {
49
+ args: { items },
50
+ render: (args) => ({
51
+ components: { Breadcrumb },
52
+ setup() {
53
+ return { args };
54
+ },
55
+ template: `<Breadcrumb v-bind="args" />`,
56
+ }),
57
+ };
58
+
59
+ export const ThreeLevels: Story = {
60
+ name: "Three Levels",
61
+ args: {
62
+ items: [
63
+ { label: "Home", to: "/" },
64
+ { label: "Services", to: "/services" },
65
+ { label: "Balayage" },
66
+ ],
67
+ },
68
+ render: (args) => ({
69
+ components: { Breadcrumb },
70
+ setup() {
71
+ return { args };
72
+ },
73
+ template: `<Breadcrumb v-bind="args" />`,
74
+ }),
75
+ };
76
+
77
+ export const CustomSeparator: Story = {
78
+ name: "Custom Separator",
79
+ args: { items, separator: ">" },
80
+ render: (args) => ({
81
+ components: { Breadcrumb },
82
+ setup() {
83
+ return { args };
84
+ },
85
+ template: `<Breadcrumb v-bind="args" />`,
86
+ }),
87
+ };
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import Breadcrumb from "../Breadcrumb.vue";
4
+ import type { BreadcrumbItem } from "~/types/components/breadcrumb";
5
+
6
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
7
+
8
+ const items: BreadcrumbItem[] = [
9
+ { label: "Services", to: "/services" },
10
+ { label: "Balayage", to: "/services/balayage" },
11
+ { label: "Highlights" },
12
+ ];
13
+
14
+ // ─── Tests ────────────────────────────────────────────────────────────────────
15
+
16
+ describe("Breadcrumb", () => {
17
+ it("mounts without error", async () => {
18
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
19
+ expect(wrapper.vm).toBeTruthy();
20
+ });
21
+
22
+ it("renders correct HTML structure", async () => {
23
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
24
+ expect(wrapper.html()).toMatchSnapshot();
25
+ });
26
+
27
+ it("renders a nav with the Breadcrumb aria-label", async () => {
28
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
29
+ expect(wrapper.attributes("aria-label")).toBe("Breadcrumb");
30
+ });
31
+
32
+ it("renders every item's label", async () => {
33
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
34
+ expect(wrapper.text()).toContain("Services");
35
+ expect(wrapper.text()).toContain("Balayage");
36
+ expect(wrapper.text()).toContain("Highlights");
37
+ });
38
+
39
+ it("renders a link for items with a to prop", async () => {
40
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
41
+ const links = wrapper.findAll("a");
42
+ expect(links.length).toBe(2);
43
+ expect(links[0]?.text()).toBe("Services");
44
+ expect(links[1]?.text()).toBe("Balayage");
45
+ });
46
+
47
+ it("renders the last item without a to prop as plain text marked as current page", async () => {
48
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
49
+ const current = wrapper.find('[aria-current="page"]');
50
+ expect(current.exists()).toBe(true);
51
+ expect(current.text()).toBe("Highlights");
52
+ });
53
+
54
+ it("does not render a separator after the last item", async () => {
55
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items } });
56
+ const separators = wrapper.findAll(".breadcrumb__separator");
57
+ expect(separators.length).toBe(items.length - 1);
58
+ });
59
+
60
+ it("uses a custom separator", async () => {
61
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items, separator: ">" } });
62
+ expect(wrapper.find(".breadcrumb__separator").text()).toBe(">");
63
+ });
64
+
65
+ it("applies a single styleClassPassthrough string", async () => {
66
+ const wrapper = await mountSuspended(Breadcrumb, { props: { items, styleClassPassthrough: "custom-class" } });
67
+ expect(wrapper.classes()).toContain("custom-class");
68
+ });
69
+
70
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
71
+ const wrapper = await mountSuspended(Breadcrumb, {
72
+ props: { items, styleClassPassthrough: ["class-a", "class-b"] },
73
+ });
74
+ expect(wrapper.classes()).toContain("class-a");
75
+ expect(wrapper.classes()).toContain("class-b");
76
+ });
77
+
78
+ it("updates classes when styleClassPassthrough prop changes", async () => {
79
+ const wrapper = await mountSuspended(Breadcrumb, {
80
+ props: { items, styleClassPassthrough: ["original"] },
81
+ });
82
+ expect(wrapper.classes()).toContain("original");
83
+ await wrapper.setProps({ styleClassPassthrough: ["updated"] });
84
+ expect(wrapper.classes()).not.toContain("original");
85
+ expect(wrapper.classes()).toContain("updated");
86
+ });
87
+ });
@@ -0,0 +1,13 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`Breadcrumb > renders correct HTML structure 1`] = `
4
+ "<nav class="breadcrumb" aria-label="Breadcrumb">
5
+ <ol class="breadcrumb__list">
6
+ <li class="breadcrumb__item"><a href="/services" class="breadcrumb__link">Services</a><span class="breadcrumb__separator" aria-hidden="true">/</span></li>
7
+ <li class="breadcrumb__item"><a href="/services/balayage" class="breadcrumb__link">Balayage</a><span class="breadcrumb__separator" aria-hidden="true">/</span></li>
8
+ <li class="breadcrumb__item"><span class="breadcrumb__label" aria-current="page">Highlights</span>
9
+ <!--v-if-->
10
+ </li>
11
+ </ol>
12
+ </nav>"
13
+ `;
@@ -0,0 +1,181 @@
1
+ # ServiceDetail — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ ### Hero banner
6
+
7
+ | Token | Default | Controls |
8
+ |---|---|---|
9
+ | `--service-detail-hero-border-radius` | `0.8rem` | Corner rounding on the hero image |
10
+ | `--service-detail-hero-min-height` | `32rem` | Minimum height of the hero banner |
11
+ | `--service-detail-hero-padding` | `2.4rem` | Padding around the breadcrumb/title/pills content |
12
+ | `--service-detail-hero-scrim` | `linear-gradient(0deg, rgb(0 0 0 / 70%) 0%, rgb(0 0 0 / 10%) 60%, transparent 100%)` | Overlay gradient behind the hero text, for legibility over the image |
13
+ | `--service-detail-hero-text-colour` | `white` | Colour of the breadcrumb, eyebrow, and title over the hero image |
14
+ | `--service-detail-breadcrumb-margin-block-end` | `0.8rem` | Space below the breadcrumb |
15
+ | `--service-detail-hero-pills-gap` | `0.8rem` | Gap between the duration and price pills |
16
+ | `--service-detail-hero-pill-bg` | `transparent` | Background of the price/duration pills (via `DisplayPill`'s `--theme-pill-bg`) |
17
+ | `--service-detail-hero-pill-border-colour` | `currentColor` | Border colour of the price/duration pills |
18
+
19
+ ### Body layout
20
+
21
+ | Token | Default | Controls |
22
+ |---|---|---|
23
+ | `--service-detail-body-gap` | `3rem` | Gap between the main column and sidebar |
24
+ | `--service-detail-body-margin-block-start` | `3rem` | Space above the two-column body |
25
+ | `--service-detail-main-section-gap` | `2.4rem` | Gap between stacked sections in the main column |
26
+
27
+ The body becomes two columns at a **900px container width** (`@container`, not viewport) — the
28
+ main column is flexible, the sidebar is `minmax(28rem, 34rem)`.
29
+
30
+ ### Process / Ideal For / FAQs
31
+
32
+ The process list is rendered with `StepperList` (`indicator-variant="circle"`, connectors on) —
33
+ these tokens are `ServiceDetail`'s own names, mapped internally onto the matching
34
+ `--stepper-list-*` tokens scoped to `.service-detail__process`:
35
+
36
+ | Token | Default | Controls |
37
+ |---|---|---|
38
+ | `--service-detail-process-step-gap` | `1.6rem` | Gap between the number circle and its text (`--stepper-list-gap`) |
39
+ | `--service-detail-process-step-padding-block` | `1.6rem` | Vertical padding per process step (`--stepper-list-padding-block`) |
40
+ | `--service-detail-process-divider-colour` | `currentColor` | Connector line between process steps (`--stepper-list-connector-color`) |
41
+ | `--service-detail-process-index-colour` | `var(--colour-text-accent)` | Colour and border of the step number circles (`--stepper-list-counter-circle-text`/`-border`) |
42
+ | `--service-detail-process-index-font-size` | `1.4rem` | Font size of the step numbers (`--stepper-list-counter-font-size`) |
43
+
44
+ For anything not covered by these (e.g. the circle's own size, `--stepper-list-counter-size`),
45
+ target `--stepper-list-*` directly under `.service-detail__process` — see
46
+ [stepper-list.md](../../../../../.claude/skills/components/stepper-list.md) for the full token API.
47
+
48
+ | Token | Default | Controls |
49
+ |---|---|---|
50
+ | `--service-detail-ideal-for-gap` | `1.2rem` | Gap between ideal-for cards |
51
+ | `--service-detail-ideal-for-item-gap` | `1rem` | Gap between icon and text within a card |
52
+ | `--service-detail-ideal-for-item-padding` | `1.6rem` | Padding inside a card |
53
+ | `--service-detail-ideal-for-item-background` | `var(--slate-09)` | Card background colour |
54
+ | `--service-detail-ideal-for-item-border-colour` | `var(--slate-06)` | Card border colour |
55
+ | `--service-detail-ideal-for-item-border-radius` | `0.4rem` | Card corner rounding |
56
+ | `--service-detail-ideal-for-icon-size` | `1.6rem` | Icon size |
57
+ | `--service-detail-ideal-for-icon-colour` | `var(--colour-text-accent)` | Icon colour |
58
+ | `--service-detail-faq-padding-block` | `1.6rem` | Vertical padding per FAQ entry |
59
+ | `--service-detail-faq-divider-colour` | `currentColor` | Divider line between FAQ entries |
60
+ | `--service-detail-faq-question-font-size` | `1.6rem` | FAQ question font size |
61
+ | `--service-detail-faq-question-margin-block-end` | `0.8rem` | Space between an FAQ question and its answer |
62
+
63
+ The ideal-for grid becomes 2 columns at a **500px container width**.
64
+
65
+ ### Sidebar
66
+
67
+ | Token | Default | Controls |
68
+ |---|---|---|
69
+ | `--service-detail-sidebar-gap` | `2rem` | Gap between the booking card and related-services block |
70
+ | `--service-detail-sidebar-sticky-offset` | `2rem` | `top` offset while the sidebar is stuck |
71
+ | `--service-detail-sidebar-label-font-size` | `1.2rem` | Font size of "Book This Service" / "You May Also Like" labels |
72
+ | `--service-detail-sidebar-label-margin-block-end` | `1.6rem` | Space below a sidebar label |
73
+ | `--service-detail-sidebar-label-letter-spacing` | `0.05em` | Letter spacing of a sidebar label |
74
+ | `--service-detail-booking-card-padding` | `2.4rem` | Padding inside the booking `GlassPanel` |
75
+ | `--service-detail-sidebar-row-gap` | `1rem` | Gap between a row's label and value |
76
+ | `--service-detail-sidebar-row-padding-block` | `1rem` | Vertical padding per price/duration/location row |
77
+ | `--service-detail-sidebar-row-divider-colour` | `currentColor` | Divider line above each price/duration/location row |
78
+ | `--service-detail-sidebar-row-label-font-size` | `1.2rem` | Font size of a row's label (e.g. "Price") |
79
+ | `--service-detail-sidebar-note-font-size` | `1.2rem` | Font size of the `sidebar-note` slot content |
80
+ | `--service-detail-sidebar-note-margin-block-start` | `1.2rem` | Space above the `sidebar-note` slot content |
81
+ | `--service-detail-related-items-gap` | `1rem` | Gap between related-service item cards |
82
+ | `--service-detail-related-item-background` | `var(--slate-09)` | Background colour of a related-service item's card |
83
+ | `--service-detail-related-item-padding` | `1rem` | Padding inside a related-service item's card |
84
+ | `--service-detail-related-item-border-colour` | `var(--slate-06)` | Border colour of a related-service item's card |
85
+ | `--service-detail-related-item-border-radius` | `0.4rem` | Corner rounding of a related-service item's card |
86
+ | `--service-detail-related-item-gap` | `1.2rem` | Gap between a related item's thumbnail and its text |
87
+ | `--service-detail-related-image-size` | `5.6rem` | Width/height of a related-service thumbnail |
88
+ | `--service-detail-related-image-border-radius` | `0.4rem` | Corner rounding of a related-service thumbnail |
89
+ | `--service-detail-related-price-font-size` | `1.2rem` | Font size of a related item's price |
90
+ | `--service-detail-muted-opacity` | `0.7` | Opacity shared by every secondary/muted text element (sidebar label, sidebar note, related price, final CTA body) |
91
+
92
+ The booking card is a `GlassPanel` — its own `--glass-panel-*` tokens also apply.
93
+
94
+ ### Final CTA banner
95
+
96
+ | Token | Default | Controls |
97
+ |---|---|---|
98
+ | `--service-detail-final-cta-gap` | `1.6rem` | Gap between the copy and the `final-cta` slot content |
99
+ | `--service-detail-final-cta-margin-block-start` | `4rem` | Space above the banner |
100
+ | `--service-detail-final-cta-padding-block-start` | `3.2rem` | Padding above the banner content, below its divider |
101
+ | `--service-detail-final-cta-divider-colour` | `currentColor` | Divider line above the banner |
102
+
103
+ The banner becomes a single row (copy left, CTA right) at a **700px container width**.
104
+
105
+ ---
106
+
107
+ ## Text content — props, not CSS
108
+
109
+ Section headings, sidebar labels, and CTA copy are **props**, not hardcoded strings:
110
+
111
+ | Prop | Default |
112
+ |---|---|
113
+ | `processHeading` | `"The Process"` |
114
+ | `idealForHeading` | `"Ideal For"` |
115
+ | `maintenanceHeading` | `"Aftercare & Maintenance"` |
116
+ | `faqsHeading` | `"Frequently Asked Questions"` |
117
+ | `bookingHeading` | `"Book This Service"` |
118
+ | `priceLabel` | `"Price"` |
119
+ | `durationLabel` | `"Duration"` |
120
+ | `locationLabel` | `"Location"` |
121
+ | `relatedServicesHeading` | `"You May Also Like"` |
122
+ | `finalCtaHeading` | `"Ready to book your appointment?"` |
123
+ | `finalCtaBody` | `"Get in touch to book your appointment."` |
124
+
125
+ ```vue
126
+ <ServiceDetail
127
+ :service-data="service"
128
+ final-cta-heading="Ready to book your colour appointment?"
129
+ final-cta-body="Mobile service across Bath — I come to you."
130
+ />
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Global theming — recommended approach
136
+
137
+ ```css
138
+ /* assets/styles/setup/07.components/service-detail.css */
139
+ :root {
140
+ --service-detail-hero-border-radius: 1.2rem;
141
+ --service-detail-process-index-colour: var(--brand-accent);
142
+ --service-detail-ideal-for-icon-colour: var(--brand-accent);
143
+ }
144
+ ```
145
+
146
+ ## Page-scoped overrides
147
+
148
+ ```css
149
+ .our-services-page {
150
+ .service-detail {
151
+ --service-detail-body-gap: 4rem;
152
+ --service-detail-sidebar-sticky-offset: 8rem; /* clear a fixed site header */
153
+ }
154
+ }
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Notes
160
+
161
+ - Routing is entirely delegated to the consumer via slots: `book-cta` (scoped `serviceData`),
162
+ `final-cta` (scoped `serviceData`), and `related-service` (scoped `service`, `index`) — same
163
+ pattern as `ServicesSection`'s `cta`/`summary-link` slots. Only `sidebar-note` has no scoped
164
+ data (it's plain content, e.g. patch-test wording).
165
+ - `related-service`'s fallback content is a non-clickable thumbnail + title + price — pass the
166
+ slot yourself to wrap it in a real link once you have a routing convention.
167
+ - The `location` row and the entire "You May Also Like" block only render when `location` /
168
+ `relatedServices` are actually passed — neither exists on the shared `Service` type, so nothing
169
+ breaks for consumer apps that don't have this data.
170
+ - `breadcrumbItems` defaults to a plain, non-linked `[category, title]` trail built from
171
+ `serviceData` — pass real `to` routes once your app's URL structure is known.
172
+ - `headerTag` (default `"h1"`) controls only the hero title; `subheadingTag` (default `"h2"`)
173
+ controls every other heading in the component (What Is It, Process, Ideal For, Aftercare, FAQs,
174
+ final CTA) — unlike `ServicesSection`, which uses one `headerTag` prop for all of them. This
175
+ component is meant to be the whole page's `<h1>`, so its subheadings need their own level.
176
+ - The FAQ question is a hardcoded `<h3>` — if you set `subheadingTag="h3"`, the FAQ questions will
177
+ sit at the same level as their own section heading rather than one below it.
178
+ - Two-/three-column breakpoints (900px body, 500px ideal-for grid, 700px final CTA) use
179
+ `@container` queries against the component's own width, not the viewport — they are not tokens
180
+ since resizing them would need corresponding `minmax()`/`grid-template-columns` changes too, not
181
+ just spacing (same rationale as `ServicesSection`'s `--services-section-grid-gap-desktop`).