srcdev-nuxt-components 9.3.6 → 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 (126) hide show
  1. package/.claude/hooks/check-component-update.sh +30 -1
  2. package/.claude/skills/components/accordian-core.md +7 -7
  3. package/.claude/skills/components/breadcrumb.md +49 -0
  4. package/.claude/skills/components/content-docs.md +2 -1
  5. package/.claude/skills/components/expanding-panel.md +5 -5
  6. package/.claude/skills/components/navigation-items.md +1 -0
  7. package/.claude/skills/components/responsive-header.md +1 -0
  8. package/.claude/skills/components/service-detail.md +156 -0
  9. package/.claude/skills/components/services-card-grid.md +9 -9
  10. package/.claude/skills/components/services-card.md +82 -30
  11. package/.claude/skills/index.md +3 -1
  12. package/.claude/skills/qa-panel.md +2 -2
  13. package/.vscode/srcdev-component-breadcrumb.code-snippets +14 -0
  14. package/.vscode/srcdev-component-service-detail.code-snippets +49 -0
  15. package/.vscode/srcdev-component-services-card.code-snippets +108 -0
  16. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +22 -16
  17. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +94 -0
  18. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +32 -0
  19. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +87 -0
  20. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +87 -0
  21. package/app/components/01.atoms/navigation/breadcrumb/tests/__snapshots__/Breadcrumb.spec.ts.snap +13 -0
  22. package/app/components/02.molecules/expandable/accordian/AccordianCore.vue +5 -1
  23. package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +22 -14
  24. package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +42 -28
  25. package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +8 -10
  26. package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +15 -47
  27. package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +4 -12
  28. package/app/components/03.organisms/responsive-header/NavigationItems.vue +91 -4
  29. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +5 -1
  30. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +2 -2
  31. package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +13 -13
  32. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +13 -13
  33. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +181 -0
  34. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +480 -0
  35. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +282 -0
  36. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +270 -0
  37. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +99 -0
  38. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +129 -0
  39. package/app/components/03.organisms/services/services-card/ServicesCard.vue +153 -39
  40. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +65 -0
  41. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +82 -0
  42. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +13 -5
  43. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +3 -7
  44. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +126 -2
  45. package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +6 -0
  46. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +51 -27
  47. package/app/components/03.organisms/services/services-section/ServicesSection.vue +19 -0
  48. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +8 -8
  49. package/app/types/components/breadcrumb.ts +4 -0
  50. package/app/types/components/index.ts +1 -0
  51. package/package.json +2 -2
  52. package/app/pages/auto-grid.vue +0 -328
  53. package/app/pages/banner-video.vue +0 -238
  54. package/app/pages/forms/examples/buttons/index.vue +0 -259
  55. package/app/pages/forms/examples/material/checkbox-radio-panels.vue +0 -244
  56. package/app/pages/forms/examples/material/cssbattle.vue +0 -60
  57. package/app/pages/forms/examples/material/text-fields.vue +0 -910
  58. package/app/pages/grid-stack.vue +0 -252
  59. package/app/pages/index.vue +0 -127
  60. package/app/pages/page-hero-highlights.vue +0 -204
  61. package/app/pages/samaritan.vue +0 -327
  62. package/app/pages/typography/hero-text.vue +0 -97
  63. package/app/pages/typography/index.vue +0 -39
  64. package/app/pages/typography/page-body.vue +0 -171
  65. package/app/pages/typography/page-heading.vue +0 -76
  66. package/app/pages/typography/page-link.vue +0 -103
  67. package/app/pages/ui/accordian.vue +0 -211
  68. package/app/pages/ui/anchor-scroll-tab-navigation.vue +0 -191
  69. package/app/pages/ui/anchor-scroll.vue +0 -367
  70. package/app/pages/ui/animated-svg-text.vue +0 -85
  71. package/app/pages/ui/block-decorators.vue +0 -142
  72. package/app/pages/ui/card-core.vue +0 -82
  73. package/app/pages/ui/carousel-basic.vue +0 -292
  74. package/app/pages/ui/carousel-flip.vue +0 -449
  75. package/app/pages/ui/carousel-infinite.vue +0 -260
  76. package/app/pages/ui/clipped-panels.vue +0 -101
  77. package/app/pages/ui/contact-section.vue +0 -323
  78. package/app/pages/ui/container-glow.vue +0 -106
  79. package/app/pages/ui/content-width.vue +0 -126
  80. package/app/pages/ui/deep-expanding-menu.vue +0 -138
  81. package/app/pages/ui/display-avatar.vue +0 -253
  82. package/app/pages/ui/display-banner.vue +0 -78
  83. package/app/pages/ui/display-chip.vue +0 -367
  84. package/app/pages/ui/display-details.vue +0 -171
  85. package/app/pages/ui/display-dialog.vue +0 -344
  86. package/app/pages/ui/display-pill.vue +0 -396
  87. package/app/pages/ui/display-prompt.vue +0 -284
  88. package/app/pages/ui/display-toast.vue +0 -311
  89. package/app/pages/ui/expanding-panel.vue +0 -326
  90. package/app/pages/ui/glowing-border.vue +0 -111
  91. package/app/pages/ui/layout-content-docs.vue +0 -72
  92. package/app/pages/ui/layout-grid-a.vue +0 -96
  93. package/app/pages/ui/layout-grid-b.vue +0 -132
  94. package/app/pages/ui/magnetic-navigation.vue +0 -50
  95. package/app/pages/ui/marquee-scroller.vue +0 -99
  96. package/app/pages/ui/mask-element.vue +0 -104
  97. package/app/pages/ui/masonry-columns.vue +0 -55
  98. package/app/pages/ui/masonry-grid-ordered-experiment.vue +0 -79
  99. package/app/pages/ui/masonry-grid-ordered.vue +0 -56
  100. package/app/pages/ui/masonry-grid-sorted.vue +0 -55
  101. package/app/pages/ui/masonry-grid.vue +0 -55
  102. package/app/pages/ui/navigation/navigation-horizontal.vue +0 -484
  103. package/app/pages/ui/navigation/site-navigation/about.vue +0 -36
  104. package/app/pages/ui/navigation/site-navigation/contact.vue +0 -39
  105. package/app/pages/ui/navigation/site-navigation/index.vue +0 -38
  106. package/app/pages/ui/navigation/site-navigation/portfolio.vue +0 -43
  107. package/app/pages/ui/navigation/site-navigation/services.vue +0 -42
  108. package/app/pages/ui/navigation/tab-navigation.vue +0 -493
  109. package/app/pages/ui/page-row.vue +0 -127
  110. package/app/pages/ui/price-list.vue +0 -65
  111. package/app/pages/ui/profile-section.vue +0 -142
  112. package/app/pages/ui/qr-code/[componentName].vue +0 -218
  113. package/app/pages/ui/rotating-carousel.vue +0 -154
  114. package/app/pages/ui/scroll-reveal-image.vue +0 -483
  115. package/app/pages/ui/section-parallax.vue +0 -54
  116. package/app/pages/ui/services/colour-finder.vue +0 -43
  117. package/app/pages/ui/services/services-cards.vue +0 -57
  118. package/app/pages/ui/services/services-section/[slug].vue +0 -61
  119. package/app/pages/ui/services/services-sections-compact.vue +0 -64
  120. package/app/pages/ui/settings.vue +0 -54
  121. package/app/pages/ui/simple-grid.vue +0 -55
  122. package/app/pages/ui/slider-gallery.vue +0 -261
  123. package/app/pages/ui/tabs-y.vue +0 -107
  124. package/app/pages/ui/tabs.vue +0 -189
  125. package/app/pages/ui/tooltips.vue +0 -104
  126. package/app/pages/ui/wipe-away-vertical.vue +0 -129
@@ -0,0 +1,129 @@
1
+ # ServicesCard — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ Every token below is consumed directly at its point of use (`var(--services-card-gap, 1rem)`,
6
+ etc.) — there is no `--_`-prefixed private indirection layer, since none of these values are
7
+ reused across multiple declarations or computed from something else. Set the token globally in
8
+ a theme file, scoped to a page wrapper, or per-instance via `styleClassPassthrough` — no
9
+ `:deep()` needed, component styles are unscoped.
10
+
11
+ ### Interaction states (whole-card-clickable only)
12
+
13
+ Only visible when the card is in whole-card-clickable mode (see the component skill doc) —
14
+ i.e. `is-clickable` is on the root — since these all default to `transparent`/`0` and only
15
+ `.is-clickable` has a `:hover`/`:focus-visible` rule at all.
16
+
17
+ | Token | Default | Controls |
18
+ |---|---|---|
19
+ | `--services-card-border-radius` | `0` | Root corner radius — also clips `.image-wrapper` (root has `overflow: hidden`), so set `--image-wrapper-border-radius` to match if you want the image's own corners rounded consistently with a rounded root |
20
+ | `--services-card-border-width` | `1px` | Border thickness |
21
+ | `--services-card-border-colour` | `transparent` | Resting (non-hover) border colour |
22
+ | `--services-card-border-colour-hover` | resting border colour | Border colour on `:hover`/`:focus-visible` — defaults to whatever `--services-card-border-colour` resolves to, so setting only the resting colour keeps the border visible on hover instead of it vanishing |
23
+ | `--services-card-outline-width` | `2px` | Outline thickness |
24
+ | `--services-card-outline-colour` | `transparent` | Resting (non-hover) outline colour |
25
+ | `--services-card-outline-colour-hover` | `transparent` | Outline colour on `:hover`/`:focus-visible` |
26
+ | `--services-card-outline-offset` | `0px` | Resting outline offset |
27
+ | `--services-card-outline-offset-hover` | `0px` | Outline offset on `:hover`/`:focus-visible` — e.g. set to a larger value than the resting offset for an outline that grows outward on hover |
28
+
29
+ ```css
30
+ /* A card that gets a visible border + outline ring when it's a whole-card link */
31
+ .services-card {
32
+ --services-card-border-colour: var(--theme-border);
33
+ --services-card-outline-colour-hover: var(--theme-border-focus);
34
+ --services-card-outline-offset-hover: 4px;
35
+ }
36
+ ```
37
+
38
+ ### Layout & sizing
39
+
40
+ | Token | Default | Controls |
41
+ |---|---|---|
42
+ | `--services-card-gap` | `1rem` | Gap between the image row and the details row |
43
+ | `--image-wrapper-aspect-ratio` | `3/4` | Image aspect ratio |
44
+ | `--image-wrapper-border-radius` | `8px` | Image corner radius |
45
+ | `--image-wrapper-padding-block` | `0 0` | Padding inside the image wrapper (block axis) |
46
+ | `--image-wrapper-padding-inline` | `0 0` | Padding inside the image wrapper (inline axis) |
47
+ | `--image-wrapper-border-image-zoom-transform` | `scale(1.05)` | Transform applied to the image on hover |
48
+ | `--details-wrapper-grid-gap` | `1rem` | Gap between eyebrow/title/description/footer |
49
+ | `--details-wrapper-padding-block` | `0` | Padding inside the details wrapper (block axis) |
50
+ | `--details-wrapper-padding-inline` | `0` | Padding inside the details wrapper (inline axis) |
51
+ | `--eyebrow-text-padding-block` | `0.8rem 0` | Padding above/below the eyebrow |
52
+ | `--hero-text-padding-block` | `2rem 1rem` | Padding above/below the title |
53
+ | `--description-padding-block` | `0 0` | Padding on the description text |
54
+ | `--description-text-colour` | `var(--colour-text-secondary)` | Description paragraph colour |
55
+ | `--description-line-height` | `1.4` | Description line height |
56
+ | `--description-line-clamp` | `100` | Number of lines the description clamps to — default is effectively unclamped; set e.g. `3` to clamp |
57
+ | `--meta-padding-block` | `1.6rem 0` | Padding above/below the meta row's divider line |
58
+ | `--meta-border-colour` | `var(--theme-border)` | Meta row's top divider colour |
59
+ | `--meta-text-colour` | `inherit` | Duration/price text colour |
60
+ | `--meta-font-size` | `1.4rem` | Duration/price text size |
61
+ | `--meta-text-transform` | `uppercase` | Text transform on the duration/price meta row |
62
+ | `--footer-padding-block` | `0` | Padding on the `.footer` wrapper (meta row + actions slot) |
63
+ | `--footer-wrapper-grid-gap` | `1rem` | Gap between the meta row and the actions slot inside `.footer` |
64
+
65
+ ```css
66
+ /* assets/styles/setup/07.components/services-card.css */
67
+ :root {
68
+ --services-card-gap: 1.4rem;
69
+ --description-line-clamp: 3;
70
+ --meta-text-transform: none;
71
+ }
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Text content — props/slots, not CSS
77
+
78
+ Duration and price text come from `serviceData.duration`/`serviceData.price` by default, or
79
+ `durationText`/`priceText` props, or the `duration`/`price` scoped slots (for icons or other
80
+ custom markup) — there is nothing to override in CSS for the text itself, only its styling via
81
+ the tokens above. See the component skill doc for slot/prop usage examples.
82
+
83
+ ---
84
+
85
+ ## Per-instance overrides via styleClassPassthrough
86
+
87
+ Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct look:
88
+
89
+ ```vue
90
+ <ServicesCard :style-class-passthrough="['featured-service']" :service-data="service" />
91
+ ```
92
+
93
+ ```css
94
+ .services-card.featured-service {
95
+ --services-card-gap: 1.6rem;
96
+ --meta-border-colour: var(--color-accent);
97
+ }
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Footer bottom-alignment (row-of-cards use)
103
+
104
+ `.footer` (the meta row + `actions` slot, grouped together) gets `margin-block-start: auto`
105
+ and the root uses `grid-template-rows: auto 1fr`, so when `ServicesCardGrid`'s default
106
+ `align-items: stretch` makes a card taller than its own content (to match a taller sibling in
107
+ the same row), the extra height goes to `.footer`'s top margin instead of leaving whitespace
108
+ below the actions slot. This keeps the meta row and actions/button aligned across a row of
109
+ cards regardless of each card's description length, without needing CSS subgrid.
110
+
111
+ `.services-card`, `.image-wrapper`, `.details-wrapper`, and `.footer` all set
112
+ `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot
113
+ button label, since `InputButtonCore`'s `.button-text` is `white-space: nowrap` with no
114
+ ellipsis — forces its content's min-content width up through the flex/grid chain and widens
115
+ that one card's grid column in `ServicesCardGrid` wider than its siblings. If a card/column
116
+ ever looks wider than the rest with content cut off at the edge, check for a long unbreakable
117
+ string in a slot before assuming it's an image sizing issue — the image is just riding along
118
+ on the widened column, not the cause.
119
+
120
+ ---
121
+
122
+ ## Notes
123
+
124
+ - `--details-wrapper-grid-gap` (details-wrapper's own children) and `--footer-wrapper-grid-gap`
125
+ (the meta row ↔ actions slot gap inside `.footer`) are separate tokens — overriding one does
126
+ not affect the other.
127
+ - `.meta`'s internal gap (between the duration and price text) is a fixed `1rem`, not a token.
128
+ - Root element tag, `href`/`external` (whole-card-clickable), `eyebrowConfig`, and `heroConfig`
129
+ are props, not CSS — see the component skill doc.
@@ -1,23 +1,44 @@
1
1
  <template>
2
- <component :is="tag" class="services-card" :class="[elementClasses]">
2
+ <component
3
+ :is="resolvedTag"
4
+ :href="isClickable ? props.href : undefined"
5
+ class="services-card"
6
+ :class="[elementClasses, { 'is-clickable': isClickable }]"
7
+ >
3
8
  <div class="image-wrapper">
4
9
  <NuxtImg :src="serviceData.image" :alt="serviceData.title" loading="lazy" class="image" />
5
10
  </div>
6
- <EyebrowText :font-size="eyebrowConfig.fontSize ?? 'large'" :tag="eyebrowConfig.tag ?? 'div'" :text-content="serviceData.subtitle" />
7
- <HeroText
8
- :tag="heroConfig.tag ?? 'h2'"
9
- :font-size="heroConfig.fontSize ?? 'heading'"
10
- :text-content="[
11
- {
12
- text: serviceData.title,
13
- styleClass: 'normal',
14
- },
15
- ]"
16
- />
17
- <div class="description">
18
- {{ serviceData.shortDescription }}
11
+ <div class="details-wrapper">
12
+ <EyebrowText
13
+ :font-size="eyebrowConfig.fontSize ?? 'large'"
14
+ :tag="eyebrowConfig.tag ?? 'div'"
15
+ :text-content="serviceData.subtitle"
16
+ />
17
+ <HeroText
18
+ :tag="heroConfig.tag ?? 'h2'"
19
+ :font-size="heroConfig.fontSize ?? 'heading'"
20
+ :text-content="[
21
+ {
22
+ text: serviceData.title,
23
+ styleClass: 'normal',
24
+ },
25
+ ]"
26
+ />
27
+ <div class="description">
28
+ {{ serviceData.shortDescription }}
29
+ </div>
30
+ <div v-if="hasFooter" class="footer">
31
+ <div v-if="hasMeta" class="meta">
32
+ <div class="meta-duration">
33
+ <slot name="duration" :service-data="serviceData">{{ durationText }}</slot>
34
+ </div>
35
+ <div class="meta-price">
36
+ <slot name="price" :service-data="serviceData">{{ priceText }}</slot>
37
+ </div>
38
+ </div>
39
+ <slot name="actions" :service-data="serviceData"></slot>
40
+ </div>
19
41
  </div>
20
- <slot name="actions" :service-data="serviceData"></slot>
21
42
  </component>
22
43
  </template>
23
44
 
@@ -37,18 +58,53 @@ interface HeroConfig {
37
58
  interface Props {
38
59
  tag?: "div" | "section" | "article";
39
60
  serviceData: Service;
61
+ href?: string;
62
+ /**
63
+ * Force a real browser navigation instead of client-side routing, even for a same-origin
64
+ * href starting with "/". See InputButtonCore's `external` prop for the full rationale.
65
+ */
66
+ external?: boolean;
40
67
  eyebrowConfig?: EyebrowConfig;
41
68
  heroConfig?: HeroConfig;
69
+ /** Overrides serviceData.duration. Ignored if the `duration` slot is used. */
70
+ durationText?: string;
71
+ /** Overrides serviceData.price. Ignored if the `price` slot is used. */
72
+ priceText?: string;
42
73
  styleClassPassthrough?: string | string[];
43
74
  }
44
75
 
45
76
  const props = withDefaults(defineProps<Props>(), {
46
77
  tag: "div",
78
+ href: undefined,
79
+ external: false,
47
80
  eyebrowConfig: () => ({}),
48
81
  heroConfig: () => ({}),
82
+ durationText: undefined,
83
+ priceText: undefined,
49
84
  styleClassPassthrough: () => [],
50
85
  });
51
86
 
87
+ const slots = useSlots();
88
+ const NuxtLink = resolveComponent("NuxtLink");
89
+
90
+ const durationText = computed(() => props.durationText ?? props.serviceData.duration);
91
+ const priceText = computed(() => props.priceText ?? props.serviceData.price);
92
+ const hasMeta = computed(() => Boolean(slots.duration || slots.price || durationText.value || priceText.value));
93
+ // Groups the meta row and actions slot so they can be pushed to the bottom of the card as
94
+ // one unit — keeps them aligned across a row of cards in ServicesCardGrid regardless of how
95
+ // long each card's description is, without needing per-row CSS subgrid across components.
96
+ const hasFooter = computed(() => Boolean(hasMeta.value || slots.actions));
97
+
98
+ // Whole card is clickable only when there's no actions slot to hold its own interactive
99
+ // content (which would otherwise end up nested inside the card's own anchor) and an href is set
100
+ const isClickable = computed(() => Boolean(!slots.actions && props.href));
101
+ const isInternalLink = computed(() => isClickable.value && props.href!.startsWith("/") && !props.external);
102
+ const resolvedTag = computed(() => {
103
+ if (!isClickable.value) return props.tag;
104
+ if (isInternalLink.value) return NuxtLink;
105
+ return "a";
106
+ });
107
+
52
108
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
53
109
 
54
110
  watch(
@@ -62,19 +118,44 @@ watch(
62
118
  <style lang="css">
63
119
  @layer components {
64
120
  .services-card {
65
- /* Consumer definable css tokens */
66
- --_eyebrow-text-margin-block: 0.8rem 0;
67
- --_hero-text-margin-block: 2rem 1rem;
68
- --_description-text-colour: var(--colour-text-secondary);
69
-
70
121
  display: grid;
71
- grid-template-rows: auto auto auto 5lh auto;
72
- gap: 1rem;
122
+ grid-template-rows: auto 1fr;
123
+ gap: var(--services-card-gap, 1rem);
124
+ min-inline-size: 0;
125
+
126
+ border-radius: var(--services-card-border-radius, 0);
127
+ border: var(--services-card-border-width, 1px) solid var(--services-card-border-colour, transparent);
128
+ outline: var(--services-card-outline-width, 2px) solid var(--services-card-outline-colour, transparent);
129
+ outline-offset: var(--services-card-outline-offset, 0px);
130
+ overflow: hidden;
131
+
132
+ transition:
133
+ border-color 0.3s ease-in-out,
134
+ outline-color 0.3s ease-in-out,
135
+ outline-offset 0.3s ease-in-out;
136
+
137
+ &.is-clickable {
138
+ color: inherit;
139
+ text-decoration: none;
140
+ cursor: pointer;
141
+
142
+ &:hover,
143
+ &:focus-visible {
144
+ /* Falls back to the resting border colour (not transparent) so setting only
145
+ --services-card-border-colour doesn't make the border vanish on hover/focus. */
146
+ border-color: var(--services-card-border-colour-hover, var(--services-card-border-colour, transparent));
147
+ outline-color: var(--services-card-outline-colour-hover, transparent);
148
+ outline-offset: var(--services-card-outline-offset-hover, 0px);
149
+ }
150
+ }
73
151
 
74
152
  .image-wrapper {
75
- aspect-ratio: 3/4;
76
- border-radius: 8px;
153
+ aspect-ratio: var(--image-wrapper-aspect-ratio, 3/4);
154
+ border-radius: var(--image-wrapper-border-radius, 8px);
77
155
  overflow: hidden;
156
+ min-inline-size: 0;
157
+ padding-block: var(--image-wrapper-padding-block, 0 0);
158
+ padding-inline: var(--image-wrapper-padding-inline, 0 0);
78
159
 
79
160
  .image {
80
161
  display: block;
@@ -84,28 +165,61 @@ watch(
84
165
  transition: transform 0.3s ease-in-out;
85
166
 
86
167
  &:hover {
87
- transform: scale(1.1);
168
+ transform: var(--image-wrapper-border-image-zoom-transform, scale(1.05));
88
169
  }
89
170
  }
90
171
  }
91
172
 
92
- .eyebrow-text {
93
- margin-block: var(--_eyebrow-text-margin-block);
94
- }
173
+ .details-wrapper {
174
+ display: flex;
175
+ flex-direction: column;
176
+ gap: var(--details-wrapper-grid-gap, 1rem);
177
+ min-inline-size: 0;
95
178
 
96
- .hero-text {
97
- margin-block: var(--_hero-text-margin-block);
98
- }
179
+ padding-block: var(--details-wrapper-padding-block, 0);
180
+ padding-inline: var(--details-wrapper-padding-inline, 0);
99
181
 
100
- .description {
101
- color: var(--_description-text-colour);
182
+ .eyebrow-text {
183
+ padding-block: var(--eyebrow-text-padding-block, 0.8rem 0);
184
+ }
102
185
 
103
- /* display: -webkit-box;
104
- -webkit-box-orient: vertical;
105
- overflow: hidden;
106
- -webkit-line-clamp: 2;
107
- line-clamp: 2;
108
- text-overflow: ellipsis; */
186
+ .hero-text {
187
+ padding-block: var(--hero-text-padding-block, 2rem 1rem);
188
+ }
189
+
190
+ .description {
191
+ color: var(--description-text-colour, var(--colour-text-secondary));
192
+ line-height: var(--description-line-height, 1.4);
193
+ padding-block: var(--description-padding-block, 0 0);
194
+
195
+ display: -webkit-box;
196
+ -webkit-box-orient: vertical;
197
+ overflow: hidden;
198
+ -webkit-line-clamp: var(--description-line-clamp, 100);
199
+ line-clamp: var(--description-line-clamp, 100);
200
+ text-overflow: ellipsis;
201
+ }
202
+
203
+ .footer {
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: var(--footer-wrapper-grid-gap, 1rem);
207
+ margin-block-start: auto;
208
+ min-inline-size: 0;
209
+ padding-block: var(--footer-padding-block, 0);
210
+ }
211
+
212
+ .meta {
213
+ display: flex;
214
+ align-items: center;
215
+ justify-content: space-between;
216
+ gap: 1rem;
217
+ padding-block: var(--meta-padding-block, 1.6rem 0);
218
+ border-block-start: 1px solid var(--meta-border-colour, var(--theme-border));
219
+ color: var(--meta-text-colour, inherit);
220
+ font-size: var(--meta-font-size, 1.4rem);
221
+ text-transform: var(--meta-text-transform, uppercase);
222
+ }
109
223
  }
110
224
  }
111
225
  }
@@ -19,6 +19,22 @@ const meta: Meta<typeof ServicesCard> = {
19
19
  control: "object",
20
20
  description: "Override hero tag and fontSize — omit keys to use defaults",
21
21
  },
22
+ href: {
23
+ control: "text",
24
+ description: "When set and no actions slot is provided, the whole card becomes a link",
25
+ },
26
+ external: {
27
+ control: "boolean",
28
+ description: "Force a plain <a> instead of NuxtLink for an internal-looking href",
29
+ },
30
+ durationText: {
31
+ control: "text",
32
+ description: "Overrides serviceData.duration in the meta row",
33
+ },
34
+ priceText: {
35
+ control: "text",
36
+ description: "Overrides serviceData.price in the meta row",
37
+ },
22
38
  styleClassPassthrough: {
23
39
  control: "object",
24
40
  description: "Additional CSS classes applied to the root element",
@@ -31,6 +47,12 @@ const meta: Meta<typeof ServicesCard> = {
31
47
  styleClassPassthrough: [],
32
48
  },
33
49
  parameters: {
50
+ backgrounds: {
51
+ default: "storybook-canvas",
52
+ options: {
53
+ "storybook-canvas": { name: "Canvas", value: "oklch(0.163 0.005 17)" },
54
+ },
55
+ },
34
56
  docs: {
35
57
  description: {
36
58
  component:
@@ -66,6 +88,26 @@ const sampleService: Service = {
66
88
  seoDescription: "Professional locs installation service.",
67
89
  };
68
90
 
91
+ const balayageService: Service = {
92
+ slug: "balayage",
93
+ category: "hair",
94
+ title: "Balayage",
95
+ subtitle: "Freehand Colour Artistry",
96
+ price: "From £95",
97
+ duration: "2.5 - 3.5 hours",
98
+ image: "https://picsum.photos/seed/balayage-card/600/800",
99
+ shortDescription: "Colour swept on by hand, bespoke to your hair's natural fall and texture.",
100
+ longDescription: "Full description not used in card view.",
101
+ heroHeading: [{ text: "Why choose balayage?", styleClass: "normal" }],
102
+ whatIsIt: "A freehand colour technique for a natural, sun-kissed result.",
103
+ process: ["Consultation", "Freehand application", "Toner", "Finish & style"],
104
+ idealFor: ["Low-maintenance colour", "Natural-looking dimension"],
105
+ maintenance: "Refresh every 3–4 months.",
106
+ faqs: [{ question: "Does balayage damage hair?", answer: "No more than any other lightening service when done correctly." }],
107
+ seoTitle: "Balayage",
108
+ seoDescription: "Freehand balayage colour service.",
109
+ };
110
+
69
111
  // ─── Stories ──────────────────────────────────────────────────────────────────
70
112
 
71
113
  export const WithActions: Story = {
@@ -192,3 +234,26 @@ export const AsArticle: Story = {
192
234
  },
193
235
  },
194
236
  };
237
+
238
+ export const DesignReplica: Story = {
239
+ name: "Design Replica — Dark Card With Meta Row",
240
+ render: (args) => ({
241
+ components: { ServicesCard },
242
+ setup() {
243
+ return { args, balayageService };
244
+ },
245
+ template: `
246
+ <div style="color-scheme:dark; background:#141414; max-width:360px; padding:2.4rem;">
247
+ <ServicesCard v-bind="args" :service-data="balayageService" />
248
+ </div>
249
+ `,
250
+ }),
251
+ parameters: {
252
+ docs: {
253
+ description: {
254
+ story:
255
+ "Reproduces the reference design: dark background, no actions slot, and the duration/price meta row sourced from serviceData.duration/serviceData.price (uppercased via CSS).",
256
+ },
257
+ },
258
+ },
259
+ };
@@ -108,6 +108,44 @@ describe("ServicesCard", () => {
108
108
  expect(wrapper.find(".image-wrapper img").attributes("loading")).toBe("lazy");
109
109
  });
110
110
 
111
+ // ─── Meta row (duration / price) ───────────────────────────────────────
112
+
113
+ it("renders serviceData.duration and serviceData.price by default", async () => {
114
+ const wrapper = await mountSuspended(ServicesCard, {
115
+ props: { serviceData: mockService },
116
+ });
117
+ expect(wrapper.find(".meta-duration").text()).toBe(mockService.duration);
118
+ expect(wrapper.find(".meta-price").text()).toBe(mockService.price);
119
+ });
120
+
121
+ it("overrides duration/price text with durationText/priceText props", async () => {
122
+ const wrapper = await mountSuspended(ServicesCard, {
123
+ props: { serviceData: mockService, durationText: "1 hour", priceText: "£75" },
124
+ });
125
+ expect(wrapper.find(".meta-duration").text()).toBe("1 hour");
126
+ expect(wrapper.find(".meta-price").text()).toBe("£75");
127
+ });
128
+
129
+ it("overrides duration/price content with duration/price slots", async () => {
130
+ const wrapper = await mountSuspended(ServicesCard, {
131
+ props: { serviceData: mockService },
132
+ slots: {
133
+ duration: '<span class="custom-duration">Quick</span>',
134
+ price: '<span class="custom-price">Free</span>',
135
+ },
136
+ });
137
+ expect(wrapper.find(".custom-duration").text()).toBe("Quick");
138
+ expect(wrapper.find(".custom-price").text()).toBe("Free");
139
+ });
140
+
141
+ it("hides the meta row when there is no duration, price, or slot content", async () => {
142
+ const serviceWithoutMeta: Service = { ...mockService, duration: "", price: "" };
143
+ const wrapper = await mountSuspended(ServicesCard, {
144
+ props: { serviceData: serviceWithoutMeta },
145
+ });
146
+ expect(wrapper.find(".meta").exists()).toBe(false);
147
+ });
148
+
111
149
  // ─── Actions slot ───────────────────────────────────────────────────────
112
150
 
113
151
  it("renders the actions slot", async () => {
@@ -128,6 +166,50 @@ describe("ServicesCard", () => {
128
166
  expect(wrapper.vm).toBeTruthy();
129
167
  });
130
168
 
169
+ // ─── Whole-card clickable (href, no actions slot) ──────────────────────
170
+
171
+ it("stays as the tag prop when href is not set", async () => {
172
+ const wrapper = await mountSuspended(ServicesCard, {
173
+ props: { serviceData: mockService },
174
+ });
175
+ expect(wrapper.element.tagName).toBe("DIV");
176
+ expect(wrapper.attributes("href")).toBeUndefined();
177
+ });
178
+
179
+ it("stays as the tag prop when href is set but an actions slot is provided", async () => {
180
+ const wrapper = await mountSuspended(ServicesCard, {
181
+ props: { serviceData: mockService, href: "/services/test-service" },
182
+ slots: { actions: '<button class="test-cta">Book now</button>' },
183
+ });
184
+ expect(wrapper.element.tagName).toBe("DIV");
185
+ expect(wrapper.attributes("href")).toBeUndefined();
186
+ });
187
+
188
+ it("renders as an anchor with href when href is set and no actions slot is provided", async () => {
189
+ const wrapper = await mountSuspended(ServicesCard, {
190
+ props: { serviceData: mockService, href: "https://example.com/book" },
191
+ });
192
+ expect(wrapper.element.tagName).toBe("A");
193
+ expect(wrapper.attributes("href")).toBe("https://example.com/book");
194
+ expect(wrapper.classes()).toContain("is-clickable");
195
+ });
196
+
197
+ it("renders as NuxtLink for an internal href", async () => {
198
+ const wrapper = await mountSuspended(ServicesCard, {
199
+ props: { serviceData: mockService, href: "/services/test-service" },
200
+ });
201
+ expect(wrapper.element.tagName).toBe("A");
202
+ expect(wrapper.attributes("href")).toBe("/services/test-service");
203
+ });
204
+
205
+ it("renders as a plain anchor for an internal href when external is true", async () => {
206
+ const wrapper = await mountSuspended(ServicesCard, {
207
+ props: { serviceData: mockService, href: "/api/auth/github", external: true },
208
+ });
209
+ expect(wrapper.element.tagName).toBe("A");
210
+ expect(wrapper.attributes("href")).toBe("/api/auth/github");
211
+ });
212
+
131
213
  // ─── eyebrowConfig ──────────────────────────────────────────────────────
132
214
 
133
215
  it("uses default eyebrow fontSize of large", async () => {
@@ -3,10 +3,18 @@
3
3
  exports[`ServicesCard > renders correct HTML structure 1`] = `
4
4
  "<div class="services-card">
5
5
  <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/test.jpg 1x, /_ipx/_/images/test.jpg 2x" alt="Test Service" loading="lazy" class="image" src="/_ipx/_/images/test.jpg"></div>
6
- <div class="eyebrow-text large">A subtitle</div>
7
- <h2 class="hero-text heading axis-horizontal">
8
- <!--v-if--><span class="text-block-0 normal">Test Service</span>
9
- </h2>
10
- <div class="description">A short description of the service.</div>
6
+ <div class="details-wrapper">
7
+ <div class="eyebrow-text large">A subtitle</div>
8
+ <h2 class="hero-text heading axis-horizontal">
9
+ <!--v-if--><span class="text-block-0 normal">Test Service</span>
10
+ </h2>
11
+ <div class="description">A short description of the service.</div>
12
+ <div class="footer">
13
+ <div class="meta">
14
+ <div class="meta-duration">60 mins</div>
15
+ <div class="meta-price">£50</div>
16
+ </div>
17
+ </div>
18
+ </div>
11
19
  </div>"
12
20
  `;
@@ -50,7 +50,7 @@ const props = withDefaults(defineProps<Props>(), {
50
50
  tag: "div",
51
51
  eyebrowConfig: () => ({}),
52
52
  heroConfig: () => ({}),
53
- hrefBase: "/ui/services/services-section/",
53
+ hrefBase: "/services/",
54
54
  buttonTextPrefix: "Enquire about",
55
55
  styleClassPassthrough: () => [],
56
56
  });
@@ -68,13 +68,9 @@ watch(
68
68
  <style lang="css">
69
69
  @layer components {
70
70
  .services-card-grid {
71
- /* Consumer definable css tokens */
72
- --_gap: 4rem;
73
- --_column-min-width: 250px;
74
-
75
71
  display: grid;
76
- grid-template-columns: repeat(auto-fit, minmax(var(--_column-min-width), 1fr));
77
- gap: var(--_gap);
72
+ grid-template-columns: repeat(auto-fit, minmax(var(--services-card-grid-column-min-width, 250px), 1fr));
73
+ gap: var(--services-card-grid-gap, 4rem);
78
74
  }
79
75
  }
80
76
  </style>