srcdev-nuxt-components 9.0.6 → 9.0.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.
@@ -0,0 +1,204 @@
1
+ # LayoutRow Component
2
+
3
+ ## Overview
4
+
5
+ `LayoutRow` is a CSS grid-based layout wrapper that controls how wide its content sits within the page. It uses a named-column grid system (full → popout → content → inset-content) so content can be precisely placed at different widths without custom CSS. It is the primary layout primitive for page sections.
6
+
7
+ ---
8
+
9
+ ## How to use this skill
10
+
11
+ When a developer asks to add a `LayoutRow`, follow the interactive flow below. Do not skip to writing code — work through the questions first to ensure the right variant is chosen. Developers unfamiliar with this system will not know what the options mean without guidance.
12
+
13
+ ---
14
+
15
+ ## Step 1 — Read the current context
16
+
17
+ Before asking anything, read the file the developer is working in and any parent components or pages to determine:
18
+
19
+ 1. **Is this LayoutRow inside another LayoutRow?**
20
+ - If yes, note the parent's `variant`. The inner grid resets relative to the parent's inner width, not the page. Warn the developer (see side effects below).
21
+ - If no, this is page-level — the grid spans the full viewport.
22
+
23
+ 2. **What is the effective current max width?**
24
+ Report this before asking any questions. Examples:
25
+ - "This row will sit at **page level** — the grid spans the full viewport width."
26
+ - "This row is nested inside a `LayoutRow variant="content"` — its maximum available width is **1064px**, not the full viewport."
27
+ - "This row is nested inside `variant="inset-content"` — maximum available width is **840px**."
28
+
29
+ 3. **What content is already nearby?** Note any sibling LayoutRows, headings, or components to give context for alignment.
30
+
31
+ ---
32
+
33
+ ## Step 2 — Ask what the row will contain
34
+
35
+ Ask the developer what is going in this row. Do not assume. Examples to listen for:
36
+
37
+ | Content type | Suggested variant |
38
+ |---|---|
39
+ | Hero, banner, full-bleed image or video | `full` or `full-content` |
40
+ | Coloured background band containing constrained content | `full` wrapping inner `content` |
41
+ | Card grid, media grid, feature row | `popout` |
42
+ | Standard page section (text + components) | `content` |
43
+ | Long-form article, blog post, form | `inset-content` |
44
+ | Sidebar or aside | `inset-content` |
45
+
46
+ Give a suggestion based on their answer, but confirm before proceeding.
47
+
48
+ ---
49
+
50
+ ## Step 3 — Present the width and margin consequences
51
+
52
+ Once you have a candidate variant (or 2–3 options), show the developer exactly what they will get at common viewport widths. Use this reference table:
53
+
54
+ ### Approximate rendered widths by variant
55
+
56
+ | Viewport | `inset-content` | `content` | `popout` | `full` |
57
+ |----------|----------------|-----------|----------|--------|
58
+ | 375px (mobile) | ~355px | ~355px | ~355px | 375px |
59
+ | 768px (tablet) | ~748px | ~748px | ~748px | 768px |
60
+ | 1080px | **840px** | ~1060px | ~1060px | 1080px |
61
+ | 1280px | **840px** | **1064px** | ~1260px | 1280px |
62
+ | 1440px | **840px** | **1064px** | **1400px** | 1440px |
63
+ | 1920px | **840px** | **1064px** | **1400px** | 1920px |
64
+
65
+ > Bold = the variant has reached its maximum and is now letterboxed. Below that threshold it fills the available width minus the minimum gutter (default `1rem` = 10px each side).
66
+
67
+ ### Approximate margin (space each side at wider viewports)
68
+
69
+ | Variant | @ 1080px | @ 1280px | @ 1440px | @ 1920px |
70
+ |---------|---------|---------|---------|---------|
71
+ | `inset-content` | ~120px | ~220px | ~300px | ~540px |
72
+ | `content` | ~10px | ~108px | ~188px | ~428px |
73
+ | `popout` | ~10px | ~10px | ~20px | ~260px |
74
+ | `full` | 0 | 0 | 0 | 0 |
75
+
76
+ Phrase this conversationally. For example:
77
+ > "With `content`, your section will max out at **1064px** wide. On a 1440px screen that leaves about **188px of margin on each side**. On a 1280px screen it's tighter — around **108px** each side. Does that sound right for what you're building?"
78
+
79
+ If they want more breathing room → suggest `inset-content`.
80
+ If they want the content to feel wider → suggest `popout`.
81
+
82
+ ---
83
+
84
+ ## Step 4 — Flag side effects for the chosen variant
85
+
86
+ Before writing code, flag any relevant consequences:
87
+
88
+ **`full` or `full-width`** — Content is completely edge-to-edge with no gutter at any viewport width. If text is placed directly inside without a nested layout wrapper it will touch screen edges on mobile. Suggest `full-content` or a nested `content` LayoutRow for the text.
89
+
90
+ **`full-content`** — Adds `--minimum-content-padding` (default `1rem` = 10px) as inline padding. Still full-bleed visually at most viewport sizes. Good for coloured bands.
91
+
92
+ **`full-content-nopad`** — Truly zero padding. Only use if the child component manages its own edge spacing.
93
+
94
+ **`popout`** — At viewports below ~1400px the popout track shrinks. Between 1280–1440px the margin is very small (~10–20px each side). If the design needs consistent breathing room at 1280px, `content` may be a better choice.
95
+
96
+ **`inset-content` or `content` nested inside a `full` LayoutRow** — This is a common and valid pattern: `full` gives edge-to-edge background, the inner row constrains the text/content. Confirm this is intentional if you see it.
97
+
98
+ **Nested LayoutRow (any variant)** — The inner grid takes the parent's `.layout-row-inner` width as 100%. The `full` variant of the inner LayoutRow will only be as wide as the parent's inner column, not the viewport. Warn the developer if this is surprising.
99
+
100
+ **`-start` / `-end` variants** — These only set `grid-column-start` or `grid-column-end`, not both. Content will stretch to the opposite edge of the grid unless the other end is also constrained. Best for intentional asymmetric designs.
101
+
102
+ ---
103
+
104
+ ## Step 5 — Confirm the remaining props
105
+
106
+ Once the variant is agreed, ask about:
107
+
108
+ 1. **`tag`** — what HTML element? Default is `div`. Common choices:
109
+ - `section` — for a thematic page section (most common)
110
+ - `article` — for self-contained content
111
+ - `header` / `footer` — for page-level header/footer rows
112
+ - `main` — for the primary content area (only once per page)
113
+ - `nav` — for navigation rows
114
+
115
+ 2. **`id`** — needed if this section is a scroll target, skip-link destination, or anchor in navigation. Ask if unsure.
116
+
117
+ 3. **`isLandmark`** — default `false`. Only set `true` if the row is a navigable landmark that needs keyboard focus. Usually `tag="section"` is sufficient — `isLandmark` is rarely needed.
118
+
119
+ 4. **`styleClassPassthrough`** — any extra classes for spacing, background colour, etc.?
120
+
121
+ ---
122
+
123
+ ## Props reference
124
+
125
+ | Prop | Type | Default | Required |
126
+ |------|------|---------|----------|
127
+ | `variant` | see variant list | — | **yes** |
128
+ | `tag` | `"div" \| "section" \| "article" \| "aside" \| "header" \| "footer" \| "main" \| "nav" \| "ul" \| "ol"` | `"div"` | no |
129
+ | `id` | `string` | `null` | no |
130
+ | `isLandmark` | `boolean` | `false` | no |
131
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
132
+ | `dataTestid` | `string` | `"layout-row"` | no |
133
+
134
+ All valid `variant` values:
135
+ `full` · `full-start` · `full-end` · `popout` · `popout-start` · `popout-end` · `content` · `content-start` · `content-end` · `inset-content` · `inset-content-start` · `inset-content-end` · `full-width` · `full-content` · `full-content-nopad`
136
+
137
+ ---
138
+
139
+ ## Usage examples
140
+
141
+ ### Standard section
142
+
143
+ ```vue
144
+ <LayoutRow variant="content" tag="section">
145
+ <p>Sits within the 1064px content track with ~188px margin at 1440px.</p>
146
+ </LayoutRow>
147
+ ```
148
+
149
+ ### Full-bleed hero, text constrained inside
150
+
151
+ ```vue
152
+ <LayoutRow variant="full" tag="section">
153
+ <!-- Background is edge-to-edge -->
154
+ <LayoutRow variant="content">
155
+ <HeroText tag="h1" :textContent="[{ text: 'Welcome' }]" />
156
+ </LayoutRow>
157
+ </LayoutRow>
158
+ ```
159
+
160
+ ### Coloured band (full width + safe padding)
161
+
162
+ ```vue
163
+ <LayoutRow variant="full-content" tag="section" :styleClassPassthrough="['bg-brand']">
164
+ <p>Full-width background, content padded by --minimum-content-padding.</p>
165
+ </LayoutRow>
166
+ ```
167
+
168
+ ### Long-form article body
169
+
170
+ ```vue
171
+ <LayoutRow variant="inset-content" tag="article">
172
+ <p>Comfortable reading column at 840px max.</p>
173
+ </LayoutRow>
174
+ ```
175
+
176
+ ### Feature / card grid (popout)
177
+
178
+ ```vue
179
+ <LayoutRow variant="popout" tag="section">
180
+ <!-- Card grid, image gallery, etc. -->
181
+ </LayoutRow>
182
+ ```
183
+
184
+ ---
185
+
186
+ ## CSS custom properties
187
+
188
+ Override in consuming app to adjust all track sizes globally:
189
+
190
+ | Property | Default | Effect |
191
+ |----------|---------|--------|
192
+ | `--popout-max-width` | `1400px` | Maximum width of the popout track |
193
+ | `--content-max-width` | `1064px` | Maximum width of the content track |
194
+ | `--inset-content-max-width` | `840px` | Maximum width of the inset-content track |
195
+ | `--minimum-content-padding` | `1rem` (10px) | Minimum gutter at small viewports |
196
+
197
+ ---
198
+
199
+ ## Notes
200
+
201
+ - Auto-imported in Nuxt — no import needed.
202
+ - `.layout-row-inner` has `container-type: inline-size` — children can use CSS container queries.
203
+ - The `full`, `full-content`, and `full-width` variants all map to `grid-column: full` — the difference is only whether inline padding is applied.
204
+ - `isLandmark` adds `tabindex="0"` and `aria-label="Layout Row Landmark"`. Prefer a semantic `tag` over this prop where possible.
@@ -0,0 +1,61 @@
1
+ # ServicesCard Component
2
+
3
+ ## Overview
4
+
5
+ `ServicesCard` renders a single service as a portrait card: image, subtitle (eyebrow), title, short description, and an `actions` slot for any CTA content. The component owns the layout and data display; all routing and button decisions are delegated to the consumer via the slot.
6
+
7
+ ## Props
8
+
9
+ | Prop | Type | Default | Required |
10
+ | ----------------------- | --------------------------------- | ------- | -------- |
11
+ | `serviceData` | `Service` | — | **yes** |
12
+ | `tag` | `"div" \| "section" \| "article"` | `"div"` | no |
13
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
14
+
15
+ ## Slots
16
+
17
+ | Slot | Slot props | Purpose |
18
+ | --------- | -------------------------- | ---------------------------------------------------------------------- |
19
+ | `actions` | `{ serviceData: Service }` | CTA area below the description — buttons, links, or any action content |
20
+
21
+ The `actions` slot receives `serviceData` as a scoped prop so the consumer can construct routes or labels from the service data without additional props.
22
+
23
+ ## Basic usage
24
+
25
+ ```vue
26
+ <ServicesCard :service-data="service">
27
+ <template #actions="{ serviceData }">
28
+ <InputButtonCore
29
+ variant="secondary"
30
+ :button-text="`More about ${serviceData.title}`"
31
+ :href="`/services/${serviceData.slug}`"
32
+ >
33
+ <template #right>
34
+ <Icon name="mdi:arrow-right" class="icon" />
35
+ </template>
36
+ </InputButtonCore>
37
+ </template>
38
+ </ServicesCard>
39
+ ```
40
+
41
+ ## Multiple actions
42
+
43
+ ```vue
44
+ <ServicesCard :service-data="service">
45
+ <template #actions="{ serviceData }">
46
+ <InputButtonCore
47
+ variant="secondary"
48
+ :button-text="`More about ${serviceData.title}`"
49
+ :href="`/services/${serviceData.slug}`"
50
+ />
51
+ <NuxtLink :to="`/services/${serviceData.slug}/contact`">Get in touch</NuxtLink>
52
+ </template>
53
+ </ServicesCard>
54
+ ```
55
+
56
+ ## Notes
57
+
58
+ - Component is auto-imported in Nuxt — no import needed.
59
+ - The `Service` type is imported from `~/types/types.services`.
60
+ - Grid rows are sized `auto 2ch auto 5lh auto` — the last row (`auto`) accommodates the `actions` slot at any height.
61
+ - Image has a `3/4` aspect ratio with a subtle scale-on-hover effect.
@@ -0,0 +1,114 @@
1
+ # ServicesSection Component
2
+
3
+ ## Overview
4
+
5
+ `ServicesSection` renders a single service as a full two-column section: image on one side, detailed content on the other. It has two modes controlled by the `isSummary` prop:
6
+
7
+ - **Summary mode** (`isSummary: true`) — compact view: eyebrow, title, price/duration, `whatIsIt` text, and a `summary-link` slot for a navigational link.
8
+ - **Full mode** (`isSummary: false`, default) — complete view: all summary content plus long description, hero heading, process stepper, ideal-for stepper, aftercare, FAQs, and a `GlassPanel` with a `cta` slot for a booking/contact button.
9
+
10
+ All routing and CTA decisions are delegated to the consumer via slots.
11
+
12
+ ## Props
13
+
14
+ | Prop | Type | Default | Required |
15
+ |------|------|---------|----------|
16
+ | `serviceData` | `Service` | — | **yes** |
17
+ | `tag` | `"div" \| "section" \| "article" \| "main"` | `"div"` | no |
18
+ | `index` | `number` | `0` | no |
19
+ | `isSummary` | `boolean` | `false` | no |
20
+ | `summaryAlignment` | `"start" \| "center" \| "end"` | `"center"` | no |
21
+ | `reverse` | `boolean` | `false` | no |
22
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | no |
23
+
24
+ ### `index` and image loading
25
+
26
+ The `index` prop controls eager vs lazy image loading. The first two sections (`index` 0 and 1) load eagerly; all others load lazily. Pass the loop index when rendering a list of sections.
27
+
28
+ ### `reverse`
29
+
30
+ Flips the image to the right column and content to the left (CSS `order: 2` on the image wrapper).
31
+
32
+ ## Slots
33
+
34
+ | Slot | Slot props | Rendered when | Purpose |
35
+ |------|-----------|---------------|---------|
36
+ | `summary-link` | `{ serviceData: Service }` | `isSummary` is `true` | Navigation link below the `whatIsIt` text in summary mode |
37
+ | `cta` | `{ serviceData: Service }` | `isSummary` is `false` | CTA button/link inside the closing `GlassPanel` in full mode |
38
+
39
+ Both slots receive `serviceData` as a scoped prop.
40
+
41
+ ## Summary mode usage
42
+
43
+ ```vue
44
+ <ServicesSection :service-data="service" :is-summary="true" :index="i">
45
+ <template #summary-link="{ serviceData }">
46
+ <LinkText
47
+ :to="`/services/${serviceData.slug}`"
48
+ :link-text="`More about ${serviceData.title}`"
49
+ :style-class-passthrough="['mb-20']"
50
+ >
51
+ <template #right>
52
+ <Icon name="mdi:arrow-right" />
53
+ </template>
54
+ </LinkText>
55
+ </template>
56
+ </ServicesSection>
57
+ ```
58
+
59
+ ## Full mode usage
60
+
61
+ ```vue
62
+ <ServicesSection :service-data="service">
63
+ <template #cta>
64
+ <InputButtonCore
65
+ variant="secondary"
66
+ button-text="Get in touch"
67
+ href="/contact"
68
+ :style-class-passthrough="['mbs-24']"
69
+ >
70
+ <template #right>
71
+ <Icon name="mdi:arrow-right" class="icon" />
72
+ </template>
73
+ </InputButtonCore>
74
+ </template>
75
+ </ServicesSection>
76
+ ```
77
+
78
+ ## Multiple CTAs in full mode
79
+
80
+ ```vue
81
+ <ServicesSection :service-data="service">
82
+ <template #cta="{ serviceData }">
83
+ <InputButtonCore variant="primary" button-text="Book now" href="/book" />
84
+ <InputButtonCore variant="secondary" button-text="Get in touch" href="/contact" />
85
+ </template>
86
+ </ServicesSection>
87
+ ```
88
+
89
+ ## Rendering a list (summary mode)
90
+
91
+ ```vue
92
+ <ServicesSection
93
+ v-for="(service, i) in services"
94
+ :key="service.slug"
95
+ :service-data="service"
96
+ :index="i"
97
+ :is-summary="true"
98
+ :reverse="i % 2 !== 0"
99
+ tag="section"
100
+ >
101
+ <template #summary-link="{ serviceData }">
102
+ <LinkText :to="`/services/${serviceData.slug}`" :link-text="`More about ${serviceData.title}`" />
103
+ </template>
104
+ </ServicesSection>
105
+ ```
106
+
107
+ ## Notes
108
+
109
+ - Component is auto-imported in Nuxt — no import needed.
110
+ - The `Service` type is imported from `~/types/types.services`.
111
+ - `summary-link` slot is guarded by `v-if="isSummary"` — it will not render in full mode even if provided.
112
+ - `cta` slot lives inside a `v-if="!isSummary"` `GlassPanel` — it will not render in summary mode.
113
+ - The section gets `aria-labelledby` automatically when `tag` is `"section"` or `"article"`, pointing to the internal heading id.
114
+ - `summaryAlignment` only has effect when `isSummary` is `true` — it aligns the info-wrapper content vertically within the grid cell.
@@ -27,7 +27,10 @@ Each skill is a single markdown file named `<area>-<task>.md`.
27
27
  └── components/
28
28
  ├── eyebrow-text.md — EyebrowText props, usage patterns, styling
29
29
  ├── hero-text.md — HeroText props, usage patterns, styling
30
- └── link-text.md LinkText props, slots, usage patterns, styling
30
+ ├── layout-row.md LayoutRow variant guide, width/margin decisions, usage patterns
31
+ ├── link-text.md — LinkText props, slots, usage patterns, styling
32
+ ├── services-card.md — ServicesCard props, actions slot, usage patterns
33
+ └── services-section.md — ServicesSection props, summary-link/cta slots, summary vs full mode
31
34
  ```
32
35
 
33
36
  ## Skill file template
@@ -17,15 +17,7 @@
17
17
  <div class="description">
18
18
  {{ serviceData.shortDescription }}
19
19
  </div>
20
- <InputButtonCore
21
- variant="secondary"
22
- :button-text="`More about ${serviceData.title}`"
23
- :href="`/ui/services/services-section/${serviceData.slug}`"
24
- >
25
- <template #right>
26
- <Icon name="mdi:arrow-right" class="icon" />
27
- </template>
28
- </InputButtonCore>
20
+ <slot name="actions" :service-data="serviceData"></slot>
29
21
  </component>
30
22
  </template>
31
23
 
@@ -33,7 +25,7 @@
33
25
  import type { Service } from "~/types/types.services";
34
26
 
35
27
  interface Props {
36
- tag?: "div" | "section" | "article" | "main" | "header" | "footer";
28
+ tag?: "div" | "section" | "article";
37
29
  serviceData: Service;
38
30
  styleClassPassthrough?: string | string[];
39
31
  }
@@ -60,7 +52,7 @@ watch(
60
52
  @layer components {
61
53
  .services-card {
62
54
  display: grid;
63
- grid-template-rows: auto 2ch auto 5lh 4.4rem;
55
+ grid-template-rows: auto 2ch auto 5lh auto;
64
56
  gap: 1rem;
65
57
 
66
58
  .image-wrapper {
@@ -1,6 +1,19 @@
1
1
  <template>
2
2
  <component :is="tag" class="services-card-grid" :class="[elementClasses]">
3
- <ServicesCard v-for="(item, index) in servicesData" :key="index" :service-data="item" />
3
+ <ServicesCard v-for="(item, index) in servicesData" :key="index" :service-data="item">
4
+ <template #actions="{ serviceData }">
5
+ <InputButtonCore
6
+ variant="secondary"
7
+ :button-text="`Enquire about ${serviceData.title}`"
8
+ :href="`/services/${serviceData.slug}`"
9
+ :style-class-passthrough="['mbs-24']"
10
+ >
11
+ <template #right>
12
+ <Icon name="mdi:arrow-right" class="icon" />
13
+ </template>
14
+ </InputButtonCore>
15
+ </template>
16
+ </ServicesCard>
4
17
  </component>
5
18
  </template>
6
19
 
@@ -8,7 +8,27 @@
8
8
  :is-summary="true"
9
9
  :reverse="props.useAlternateReverse ? index % 2 !== 0 : false"
10
10
  :summary-alignment="summaryAlignment"
11
- />
11
+ >
12
+ <template #summary-link="{ serviceData }">
13
+ <LinkText
14
+ :to="`/services/${serviceData.slug}`"
15
+ :link-text="`More about ${serviceData.title}`"
16
+ :style-class-passthrough="['mb-20']"
17
+ />
18
+ </template>
19
+ <template #cta="{ serviceData }">
20
+ <InputButtonCore
21
+ variant="secondary"
22
+ :button-text="`Enquire about ${serviceData.title}`"
23
+ href="#"
24
+ :style-class-passthrough="['mbs-24']"
25
+ >
26
+ <template #right>
27
+ <Icon name="mdi:arrow-right" class="icon" />
28
+ </template>
29
+ </InputButtonCore>
30
+ </template>
31
+ </ServicesSection>
12
32
  </component>
13
33
  </template>
14
34
 
@@ -48,6 +48,8 @@
48
48
  {{ serviceData.whatIsIt }}
49
49
  </p>
50
50
 
51
+ <slot v-if="isSummary" name="summary-link" :service-data="serviceData"></slot>
52
+
51
53
  <HeroText
52
54
  v-if="!isSummary"
53
55
  tag="h2"
@@ -137,16 +139,7 @@
137
139
  :style-class-passthrough="['mbs-0', 'mbe-20']"
138
140
  />
139
141
  <p class="page-body-normal">Mobile service across Bath — I come to you.</p>
140
- <InputButtonCore
141
- variant="secondary"
142
- button-text="Get in touch"
143
- href="#"
144
- :style-class-passthrough="['mbs-24']"
145
- >
146
- <template #right>
147
- <Icon name="mdi:arrow-right" class="icon" />
148
- </template>
149
- </InputButtonCore>
142
+ <slot name="cta" :service-data="serviceData"></slot>
150
143
  </GlassPanel>
151
144
  </div>
152
145
  </div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.0.6",
4
+ "version": "9.0.8",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",