srcdev-nuxt-components 9.1.6 → 9.1.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.
- package/.claude/settings.json +2 -1
- package/.claude/skills/components/page-hero-highlights.md +35 -18
- package/.claude/skills/components/services-card-grid.md +24 -25
- package/.claude/skills/css-grid-max-width-gutters.md +38 -2
- package/.claude/skills/index.md +1 -0
- package/.claude/skills/security-review.md +151 -0
- package/app/assets/styles/setup/01.config/_head.css +24 -12
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +6 -2
- package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +3 -1
- package/app/components/03.organisms/services/services-section/ServicesSection.vue +8 -6
- package/app/components/04.templates/page-hero-highlights/PageHeroHighlights.vue +50 -11
- package/app/components/04.templates/page-hero-highlights/PageHeroHighlightsHeader.vue +12 -2
- package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlights.stories.ts +23 -12
- package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +12 -21
- package/app/components/04.templates/page-hero-highlights/tests/__snapshots__/PageHeroHighlights.spec.ts.snap +1 -1
- package/app/pages/page-hero-highlights.vue +127 -8
- package/package.json +1 -1
package/.claude/settings.json
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"additionalDirectories": [
|
|
22
22
|
"/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/content-wrappers/content-width",
|
|
23
23
|
"/Users/simoncornforth/websites/nuxt-components/.claude/skills",
|
|
24
|
-
"/Users/simoncornforth/websites/nuxt-components/app/components/02.molecules/navigation/site-navigation/tests"
|
|
24
|
+
"/Users/simoncornforth/websites/nuxt-components/app/components/02.molecules/navigation/site-navigation/tests",
|
|
25
|
+
"/Users/simoncornforth/websites/luxury-locs-by-natasha-nuxt3/app/pages"
|
|
25
26
|
]
|
|
26
27
|
}
|
|
27
28
|
}
|
|
@@ -17,8 +17,8 @@ The layout uses a 4-row CSS Grid with `subgrid` — no `translate`, negative mar
|
|
|
17
17
|
| `tag` | `"div" \| "section" \| "main"` | `"div"` | Root element tag |
|
|
18
18
|
| `highlightsEqualWidths` | `boolean` | `false` | Equal-width grid columns for highlight items |
|
|
19
19
|
| `highlightsJustify` | `"start" \| "center" \| "end" \| "space-between" \| "space-around"` | `"start"` | Alignment of highlight items along the main axis |
|
|
20
|
-
| `
|
|
21
|
-
| `contentAlign` | `"start" \| "center"` | `"center"` | When `
|
|
20
|
+
| `widthConstrained` | `boolean` | `false` | When `true`, caps the central column at `--width-constrained` (default `1064px`). Gutters grow responsively to enforce the constraint. |
|
|
21
|
+
| `contentAlign` | `"start" \| "center"` | `"center"` | When `widthConstrained` is `true`: `"center"` grows gutters equally; `"start"` pins content to the left with a fixed left gutter. |
|
|
22
22
|
| `contentPanel` | `boolean` | `true` | When `true`, renders a decorative panel behind the content slot and offsets the highlights strip. Set to `false` for a flat layout with no backdrop. |
|
|
23
23
|
| `highlightTitleBaseline`| `boolean` | `false` | When `true`, fixes the highlight title row to a set height so titles align at a common baseline. Override `--highlight-title-height` to tune. |
|
|
24
24
|
| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes on the root element |
|
|
@@ -66,7 +66,7 @@ Each slot accepts any content, but these library components are natural fits:
|
|
|
66
66
|
Example with `HeroText` in the header slot:
|
|
67
67
|
|
|
68
68
|
```vue
|
|
69
|
-
<PageHeroHighlights tag="section"
|
|
69
|
+
<PageHeroHighlights tag="section" :width-constrained="true">
|
|
70
70
|
<template #header="{ headingId }">
|
|
71
71
|
<HeroText :heading-id="headingId" text="Welcome back" accent-text="Simon" />
|
|
72
72
|
</template>
|
|
@@ -106,11 +106,13 @@ Located at: `app/components/04.templates/page-hero-highlights/PageHeroHighlights
|
|
|
106
106
|
|
|
107
107
|
### CSS tokens
|
|
108
108
|
|
|
109
|
-
| Token
|
|
110
|
-
|
|
|
111
|
-
| `--phh-padding-block`
|
|
112
|
-
| `--phh-
|
|
113
|
-
| `--phh-
|
|
109
|
+
| Token | Default | Description |
|
|
110
|
+
| -------------------------- | ---------------- | ---------------------------------------------------- |
|
|
111
|
+
| `--phh-padding-block-mobile` | `1.6rem 3.2rem` | Block padding (start end) at mobile widths |
|
|
112
|
+
| `--phh-padding-block-tablet` | `2.4rem 4.8rem` | Block padding (start end) at ≥768px |
|
|
113
|
+
| `--phh-padding-block-desktop` | `3.2rem 6.4rem` | Block padding (start end) at ≥1024px |
|
|
114
|
+
| `--phh-gap` | `1.6rem` | Gap between `#start` and `#end` areas |
|
|
115
|
+
| `--phh-end-gap` | `0.8rem` | Gap between items within `#end` |
|
|
114
116
|
|
|
115
117
|
### Usage
|
|
116
118
|
|
|
@@ -170,17 +172,26 @@ By default, highlight items size to their content (`flex-wrap`). Pass `:highligh
|
|
|
170
172
|
|
|
171
173
|
## Constraining the central column width
|
|
172
174
|
|
|
173
|
-
Pass
|
|
175
|
+
Pass `:width-constrained="true"` to cap the content column at `--width-constrained` (default `1064px`). The gutters grow responsively to enforce it — full-bleed backgrounds are unaffected and `subgrid` continues to work. Use `content-align` to pin to the left or centre:
|
|
174
176
|
|
|
175
177
|
```vue
|
|
176
|
-
<!-- Centred, capped at 1064px -->
|
|
177
|
-
<PageHeroHighlights
|
|
178
|
+
<!-- Centred, capped at --width-constrained (1064px) -->
|
|
179
|
+
<PageHeroHighlights :width-constrained="true" content-align="center">...</PageHeroHighlights>
|
|
178
180
|
|
|
179
|
-
<!-- Left-pinned
|
|
180
|
-
<PageHeroHighlights
|
|
181
|
+
<!-- Left-pinned (right side takes remaining space) -->
|
|
182
|
+
<PageHeroHighlights :width-constrained="true" content-align="start">...</PageHeroHighlights>
|
|
181
183
|
```
|
|
182
184
|
|
|
183
|
-
|
|
185
|
+
The maximum width value and gutter sizes are all CSS tokens — override them via `styleClassPassthrough` if you need different values:
|
|
186
|
+
|
|
187
|
+
```css
|
|
188
|
+
.my-page-hero {
|
|
189
|
+
--width-constrained: 1200px;
|
|
190
|
+
--page-hero-highlights-gutter-desktop: 48px;
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
See [css-grid-width-constrained-gutters.md](../css-grid-width-constrained-gutters.md) for the underlying pattern explanation.
|
|
184
195
|
|
|
185
196
|
## Local style override scaffold
|
|
186
197
|
|
|
@@ -199,6 +210,12 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
|
|
|
199
210
|
─────────────────────────────────────────────────────────────────── */
|
|
200
211
|
.page-hero-highlights {
|
|
201
212
|
&.my-page-hero {
|
|
213
|
+
/* Grid layout */
|
|
214
|
+
/* --width-constrained: 1064px; */
|
|
215
|
+
/* --page-hero-highlights-gutter-mobile: 16px; */
|
|
216
|
+
/* --page-hero-highlights-gutter-tablet: 40px; */
|
|
217
|
+
/* --page-hero-highlights-gutter-desktop: 32px; */
|
|
218
|
+
|
|
202
219
|
/* Header zone */
|
|
203
220
|
/* --header-row-background-colour: darkblue; */
|
|
204
221
|
|
|
@@ -239,8 +256,6 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
|
|
|
239
256
|
</style>
|
|
240
257
|
```
|
|
241
258
|
|
|
242
|
-
> **Note:** The minimum gutter width (`16px`) and layout behaviour are not overridable via CSS custom properties. Use the `max-width` and `content-align` props to control column constraints.
|
|
243
|
-
|
|
244
259
|
## Grid structure (reference)
|
|
245
260
|
|
|
246
261
|
```text
|
|
@@ -254,6 +269,8 @@ row4: page content (never underflows highlights)
|
|
|
254
269
|
`.header-row` spans cols 1–3, rows 1–2 (edge-to-edge bg). `.header-slot` is placed in row 1 only.
|
|
255
270
|
`.content-row` spans cols 1–3, rows 3–4 (bg fills behind highlights; `.content-slot` is placed in row 4 only). The decorative border behind `.content-slot` is rendered via `.content-row:before` — there is no separate DOM element for it.
|
|
256
271
|
|
|
272
|
+
Grid columns are determined entirely by CSS — no `v-bind`. The `widthConstrained` and `contentAlign` props add CSS classes (`width-constrained`, `start`, `center`) which select the appropriate `grid-template-columns` rule.
|
|
273
|
+
|
|
257
274
|
## Layout pitfall: do not use `grid-template-rows: subgrid` inside `.highlights-row`
|
|
258
275
|
|
|
259
276
|
The `.highlights-row` element spans rows 2–3 of the parent grid (the "straddle"). If you add an inner grid to `.highlights-row` (e.g. to extend `equal-widths` behaviour) and include `grid-template-rows: subgrid`, auto-placed items will only occupy row 1 of the subgrid (= parent row 2). Parent row 3 collapses to 0-height, destroying the straddle effect — `.content-row` appears immediately below the highlights instead of overlapping it.
|
|
@@ -280,5 +297,5 @@ The `.highlights-row` element spans rows 2–3 of the parent grid (the "straddle
|
|
|
280
297
|
- Component is auto-imported in Nuxt — no import needed.
|
|
281
298
|
- Lives in `app/components/04.templates/page-hero-highlights/`.
|
|
282
299
|
- Storybook title: `"Templates/PageHeroHighlights"`.
|
|
283
|
-
-
|
|
284
|
-
-
|
|
300
|
+
- **`contentAlign` has no effect when `widthConstrained` is `false`** — both gutters hold their responsive default.
|
|
301
|
+
- **Gutter sizes are CSS tokens** — `--page-hero-highlights-gutter-mobile/tablet/desktop` are all overridable. The responsive switching between them (via `@container`) is handled internally and cannot be overridden.
|
|
@@ -6,21 +6,31 @@
|
|
|
6
6
|
|
|
7
7
|
## Props
|
|
8
8
|
|
|
9
|
-
| Prop | Type | Default
|
|
10
|
-
| ----------------------- | --------------------------------- |
|
|
11
|
-
| `servicesData` | `Service[]` | —
|
|
12
|
-
| `tag` | `"div" \| "section" \| "main"` | `"div"`
|
|
13
|
-
| `eyebrowConfig` | `EyebrowConfig` | `{}`
|
|
14
|
-
| `heroConfig` | `HeroConfig` | `{}`
|
|
15
|
-
| `
|
|
9
|
+
| Prop | Type | Default | Required |
|
|
10
|
+
| ----------------------- | --------------------------------- | -------------------------------- | -------- |
|
|
11
|
+
| `servicesData` | `Service[]` | — | **yes** |
|
|
12
|
+
| `tag` | `"div" \| "section" \| "main"` | `"div"` | no |
|
|
13
|
+
| `eyebrowConfig` | `EyebrowConfig` | `{}` | no |
|
|
14
|
+
| `heroConfig` | `HeroConfig` | `{}` | no |
|
|
15
|
+
| `hrefBase` | `string` | `"/ui/services/services-section/"` | no |
|
|
16
|
+
| `buttonTextPrefix` | `string` | `"Enquire about"` | no |
|
|
17
|
+
| `styleClassPassthrough` | `string \| string[]` | `[]` | no |
|
|
16
18
|
|
|
17
19
|
Both config props are passed through to every `ServicesCard` in the grid unchanged. See [services-card.md](./services-card.md) for the full `EyebrowConfig` / `HeroConfig` key reference.
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
### Button link and text
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
Each card's CTA button is built internally as `${buttonTextPrefix} ${service.title}` linking to `${hrefBase}${service.slug}`. Override both at the call site:
|
|
24
|
+
|
|
25
|
+
```vue
|
|
26
|
+
<ServicesCardGrid
|
|
27
|
+
:services-data="servicesData ?? []"
|
|
28
|
+
href-base="/services/"
|
|
29
|
+
button-text-prefix="More on"
|
|
30
|
+
/>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> **Note**: `ServicesCardGrid` does **not** forward a consumer `#actions` slot to its internal `ServicesCard` instances. Use `hrefBase` / `buttonTextPrefix` props to customise the CTA.
|
|
24
34
|
|
|
25
35
|
## CSS custom properties
|
|
26
36
|
|
|
@@ -47,20 +57,9 @@ Set on `.services-card-grid` (or scoped to a page class):
|
|
|
47
57
|
:services-data="servicesData ?? []"
|
|
48
58
|
:eyebrow-config="{ fontSize: 'large' }"
|
|
49
59
|
:hero-config="{ tag: 'h2', fontSize: 'heading' }"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
variant="secondary"
|
|
54
|
-
:button-text="`Enquire about ${serviceData.title}`"
|
|
55
|
-
:href="`/services/${serviceData.slug}`"
|
|
56
|
-
:style-class-passthrough="['mbs-24']"
|
|
57
|
-
>
|
|
58
|
-
<template #right>
|
|
59
|
-
<Icon name="mdi:arrow-right" class="icon" />
|
|
60
|
-
</template>
|
|
61
|
-
</InputButtonCore>
|
|
62
|
-
</template>
|
|
63
|
-
</ServicesCardGrid>
|
|
60
|
+
href-base="/services/"
|
|
61
|
+
button-text-prefix="More on"
|
|
62
|
+
/>
|
|
64
63
|
</LayoutRow>
|
|
65
64
|
</template>
|
|
66
65
|
</NuxtLayout>
|
|
@@ -31,10 +31,47 @@ Left gutter stays fixed, content column is capped at `MAX_WIDTH`, remaining spac
|
|
|
31
31
|
|
|
32
32
|
## In Vue with a prop
|
|
33
33
|
|
|
34
|
+
### Option A — CSS tokens + class selectors (preferred when max-width is a fixed design value)
|
|
35
|
+
|
|
36
|
+
When the max-width and gutter values are fixed design tokens (not arbitrary consumer strings), express the logic entirely in CSS using a boolean `maxWidth` prop that adds a class:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
interface Props {
|
|
40
|
+
maxWidth?: boolean;
|
|
41
|
+
contentAlign?: "start" | "center";
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```css
|
|
46
|
+
.component {
|
|
47
|
+
--max-width: 1064px;
|
|
48
|
+
--gutter: 16px;
|
|
49
|
+
|
|
50
|
+
display: grid;
|
|
51
|
+
|
|
52
|
+
&.max-width {
|
|
53
|
+
grid-template-columns: var(--gutter) 1fr var(--gutter);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
&:not(.max-width) {
|
|
57
|
+
&.start {
|
|
58
|
+
grid-template-columns: var(--gutter) minmax(0, var(--max-width)) minmax(var(--gutter), 1fr);
|
|
59
|
+
}
|
|
60
|
+
&.center {
|
|
61
|
+
grid-template-columns: max(var(--gutter), (100% - var(--max-width)) / 2) 1fr
|
|
62
|
+
max(var(--gutter), (100% - var(--max-width)) / 2);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Consumers can override `--max-width` and `--gutter` via `styleClassPassthrough` without touching the prop. This is the approach used by `PageHeroHighlights`.
|
|
69
|
+
|
|
70
|
+
### Option B — computed string with `v-bind` (use when max-width is a dynamic consumer prop)
|
|
71
|
+
|
|
34
72
|
Because `v-bind()` in `<style>` can't be nested inside CSS functions like `max()`, build the column string as a computed and bind the whole value:
|
|
35
73
|
|
|
36
74
|
```ts
|
|
37
|
-
// Props
|
|
38
75
|
interface Props {
|
|
39
76
|
maxWidth?: string; // e.g. "1064px"
|
|
40
77
|
contentAlign?: "start" | "center";
|
|
@@ -45,7 +82,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
45
82
|
contentAlign: "center",
|
|
46
83
|
});
|
|
47
84
|
|
|
48
|
-
// Computed column string
|
|
49
85
|
const gridColumns = computed(() => {
|
|
50
86
|
if (!props.maxWidth) return "16px 1fr 16px";
|
|
51
87
|
if (props.contentAlign === "start") return `16px minmax(0, ${props.maxWidth}) 1fr`;
|
package/.claude/skills/index.md
CHANGED
|
@@ -20,6 +20,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
20
20
|
.claude/skills/
|
|
21
21
|
├── index.md — this file
|
|
22
22
|
├── performance-review.md — spawn a subagent to inspect recently written code for Vue/Nuxt performance issues before dev handoff
|
|
23
|
+
├── security-review.md — spawn a subagent to inspect recently written code for security vulnerabilities (XSS, injection, data exposure, etc.) before dev handoff
|
|
23
24
|
├── storybook-add-story.md — create a Storybook story for a component
|
|
24
25
|
├── storybook-add-font.md — add a new font to Storybook
|
|
25
26
|
├── testing-add-unit-test.md — create a Vitest unit test with snapshots
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Security Review
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Spawn a subagent to inspect recently written or modified code for security issues and report findings back before handoff to dev. Run this after code is written and ready for review.
|
|
6
|
+
|
|
7
|
+
## When to invoke
|
|
8
|
+
|
|
9
|
+
Invoke this skill (`/security-review`) after completing any of:
|
|
10
|
+
- A new component that accepts user input or renders external data
|
|
11
|
+
- A significant edit to an existing component
|
|
12
|
+
- A new composable that handles URLs, tokens, or external data
|
|
13
|
+
- Any code that constructs URLs, HTML, or interacts with browser APIs
|
|
14
|
+
|
|
15
|
+
## Steps
|
|
16
|
+
|
|
17
|
+
### 1. Identify files to review
|
|
18
|
+
|
|
19
|
+
Collect the list of files changed in this conversation. If unclear, run `git diff --name-only HEAD` to get recently modified files.
|
|
20
|
+
|
|
21
|
+
### 2. Spawn the security review subagent
|
|
22
|
+
|
|
23
|
+
Use the Agent tool with `subagent_type: "general-purpose"` and the following prompt, substituting `{FILE_LIST}` with the actual file paths:
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
**Subagent prompt template:**
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
You are a Vue 3 / Nuxt security reviewer. Inspect the following files for security vulnerabilities and produce a concise report.
|
|
31
|
+
|
|
32
|
+
Files to review:
|
|
33
|
+
{FILE_LIST}
|
|
34
|
+
|
|
35
|
+
Read each file in full, then check for the following issues. Report only issues that are actually present — do not flag hypothetical problems.
|
|
36
|
+
|
|
37
|
+
─── XSS / Injection ─────────────────────────────────────────────
|
|
38
|
+
□ `v-html` used with any non-static string (potential XSS — must be sanitised before use)
|
|
39
|
+
□ Template literals passed to `v-html` or `innerHTML`
|
|
40
|
+
□ User-controlled data rendered without escaping
|
|
41
|
+
□ `eval()`, `new Function()`, or `setTimeout(string)` usage
|
|
42
|
+
|
|
43
|
+
─── URL / Redirect Safety ───────────────────────────────────────
|
|
44
|
+
□ `href` or `src` built from user-supplied data without validation (open redirect / javascript: injection)
|
|
45
|
+
□ Dynamic `<script src>` or `<link href>` construction
|
|
46
|
+
□ `window.location` assignment from unvalidated input
|
|
47
|
+
|
|
48
|
+
─── Data Exposure ───────────────────────────────────────────────
|
|
49
|
+
□ Secrets, API keys, or tokens hardcoded or logged to console
|
|
50
|
+
□ Sensitive data stored in `localStorage` / `sessionStorage` without encryption
|
|
51
|
+
□ `useRuntimeConfig()` public keys that expose values that should be private (server-only)
|
|
52
|
+
□ Props or emits that inadvertently expose internal state to the DOM (e.g. `data-*` attributes)
|
|
53
|
+
|
|
54
|
+
─── Component / Props ───────────────────────────────────────────
|
|
55
|
+
□ `inheritAttrs: true` on components that accept `href` / `src` — an attacker can pass arbitrary attributes through
|
|
56
|
+
□ Props that accept raw HTML strings without a sanitisation step
|
|
57
|
+
□ Dynamic `is` prop on `<component>` bound to user-controlled data (component injection)
|
|
58
|
+
|
|
59
|
+
─── Event Handling ──────────────────────────────────────────────
|
|
60
|
+
□ `@click.native` or direct DOM event listeners on user-supplied elements
|
|
61
|
+
□ `postMessage` handlers without origin validation
|
|
62
|
+
□ Clipboard API writes of unvalidated data
|
|
63
|
+
|
|
64
|
+
─── Dependencies / Imports ──────────────────────────────────────
|
|
65
|
+
□ Dynamic `import()` paths constructed from user input
|
|
66
|
+
□ Imports from non-registry URLs (e.g. raw GitHub, CDN) rather than npm packages
|
|
67
|
+
|
|
68
|
+
─── Broken Access Control (OWASP A01) ───────────────────────────
|
|
69
|
+
□ `v-if` used as the sole gate for sensitive UI — this is cosmetic only, not a real access check
|
|
70
|
+
□ Route middleware (`definePageMeta({ middleware })`) that can be bypassed by navigating directly
|
|
71
|
+
□ Server routes or API handlers that trust `event.context.auth` / headers set by the client
|
|
72
|
+
□ Permissions or roles derived solely from client-side reactive state with no server validation
|
|
73
|
+
|
|
74
|
+
─── Cryptographic Failures (OWASP A02) ──────────────────────────
|
|
75
|
+
□ `Math.random()` used to generate tokens, IDs, or nonces (not cryptographically secure — use `crypto.randomUUID()` or `crypto.getRandomValues()`)
|
|
76
|
+
□ `btoa()` / `atob()` used to "encode" sensitive values (this is encoding, not encryption)
|
|
77
|
+
□ Sensitive values hashed with MD5 or SHA-1 (use SHA-256+ or bcrypt/argon2 for passwords)
|
|
78
|
+
□ Passwords or secrets stored in plain text in state, localStorage, or cookies
|
|
79
|
+
|
|
80
|
+
─── Security Misconfiguration (OWASP A05) ───────────────────────
|
|
81
|
+
□ `nuxt.config.ts` missing security headers (`Content-Security-Policy`, `X-Frame-Options`, `Referrer-Policy`)
|
|
82
|
+
□ CORS configured with `origin: "*"` on routes that return sensitive data
|
|
83
|
+
□ Verbose error messages or stack traces returned to the client from server routes
|
|
84
|
+
□ `devtools: true` or debug flags that could be active in production builds
|
|
85
|
+
□ `runtimeConfig` values that mix public/private — anything in `public:` is exposed to the client bundle
|
|
86
|
+
|
|
87
|
+
─── Authentication Failures (OWASP A07) ─────────────────────────
|
|
88
|
+
□ Auth tokens stored in `localStorage` or `sessionStorage` (vulnerable to XSS — prefer `httpOnly` cookies)
|
|
89
|
+
□ Auth state held only in client-side reactive state (`useState`, `ref`) with no server-side verification
|
|
90
|
+
□ JWTs decoded client-side without signature verification before trusting claims
|
|
91
|
+
□ Session IDs or tokens exposed in URL query parameters or `data-*` attributes
|
|
92
|
+
□ No expiry enforced on tokens or session state
|
|
93
|
+
|
|
94
|
+
─── Software & Data Integrity (OWASP A08) ───────────────────────
|
|
95
|
+
□ `JSON.parse()` called on untrusted/external data without a validation step (schema check or try/catch with type guard)
|
|
96
|
+
□ `Object.assign({}, userInput)` or `{ ...userInput }` spread onto internal objects — risk of prototype pollution if input is not sanitised
|
|
97
|
+
□ External `<script>` tags added via `useHead` without a `crossorigin` and `integrity` (SRI) attribute
|
|
98
|
+
□ Dynamic `import()` resolved from user-supplied strings (supply chain / path traversal risk)
|
|
99
|
+
|
|
100
|
+
─── Logging & Monitoring (OWASP A09) ────────────────────────────
|
|
101
|
+
□ `console.log` / `console.error` statements that output tokens, passwords, or PII
|
|
102
|
+
□ Error handlers that re-throw raw server errors (including DB messages) to the client response
|
|
103
|
+
□ Caught exceptions silently swallowed with no logging — makes incidents invisible
|
|
104
|
+
|
|
105
|
+
─── Server-Side Request Forgery (OWASP A10) ─────────────────────
|
|
106
|
+
□ `$fetch` / `useFetch` / `ofetch` called server-side with a URL constructed from user input (SSRF — attacker can target internal services)
|
|
107
|
+
□ Proxy or redirect endpoints that forward to a caller-supplied URL without an allowlist check
|
|
108
|
+
□ `useAsyncData` key derived from user input used to construct a server-side fetch URL
|
|
109
|
+
|
|
110
|
+
─── CSRF ────────────────────────────────────────────────────────
|
|
111
|
+
□ State-mutating server routes (POST/PUT/DELETE) that rely solely on cookies without a CSRF token or `SameSite=Strict`
|
|
112
|
+
□ Forms that submit to API routes with no CSRF protection and no `Content-Type: application/json` enforcement (which prevents simple-form cross-origin attacks)
|
|
113
|
+
|
|
114
|
+
─── Nuxt-specific ───────────────────────────────────────────────
|
|
115
|
+
□ Server routes or API handlers that trust client-sent data without validation
|
|
116
|
+
□ `useFetch` / `$fetch` URLs built from user input without sanitisation
|
|
117
|
+
□ `definePageMeta` `middleware` bypassed by conditional rendering
|
|
118
|
+
□ SSR hydration mismatches caused by client-only globals accessed during render
|
|
119
|
+
|
|
120
|
+
─── Report format ───────────────────────────────────────────────
|
|
121
|
+
For each issue found, output:
|
|
122
|
+
|
|
123
|
+
**File:** `path/to/file.vue` (line N)
|
|
124
|
+
**Issue:** One-sentence description of the vulnerability
|
|
125
|
+
**Severity:** Low / Medium / High / Critical
|
|
126
|
+
**Fix:** Concrete code change or approach to resolve it
|
|
127
|
+
|
|
128
|
+
Group findings by file. If no issues are found in a file, skip it.
|
|
129
|
+
End the report with a one-line summary: total issues found, breakdown by severity.
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
### 3. Review the report
|
|
135
|
+
|
|
136
|
+
Read the subagent's findings. For each **Critical** or **High** severity issue:
|
|
137
|
+
- Apply the fix immediately if it is clear-cut and does not change behaviour
|
|
138
|
+
- Flag it to the user with the suggested fix if it requires a design decision
|
|
139
|
+
|
|
140
|
+
For **Medium** and **Low** severity issues, present them as a list for the user to decide on.
|
|
141
|
+
|
|
142
|
+
### 4. Confirm completion
|
|
143
|
+
|
|
144
|
+
After applying any fixes, re-run the subagent on the modified files to confirm no new issues were introduced.
|
|
145
|
+
|
|
146
|
+
## Notes
|
|
147
|
+
|
|
148
|
+
- This skill is read-only when used via the subagent — it does not edit files directly. Edits are made by the main agent after reviewing the report.
|
|
149
|
+
- Report only issues that are actually present in the code — do not flag theoretical future vulnerabilities.
|
|
150
|
+
- The subagent uses `subagent_type: "general-purpose"` so it has access to all tools (Read, Grep, Glob) needed to inspect the files.
|
|
151
|
+
- Run alongside `/performance-review` — both can be spawned in parallel as independent subagents.
|
|
@@ -15,19 +15,31 @@ html {
|
|
|
15
15
|
transition:
|
|
16
16
|
background-color 0.4s ease,
|
|
17
17
|
color 0.4s ease;
|
|
18
|
-
|
|
18
|
+
scrollbar-gutter: stable;
|
|
19
19
|
|
|
20
20
|
overflow-x: clip;
|
|
21
|
-
}
|
|
22
|
-
body {
|
|
23
|
-
background-color: var(--page-bg, lightgray);
|
|
24
|
-
color: var(--colour-text-default);
|
|
25
|
-
font-family: var(--font-family);
|
|
26
|
-
font-size: var(--step-4);
|
|
27
|
-
min-height: 100dvh;
|
|
28
|
-
transition:
|
|
29
|
-
background-color 0.4s ease,
|
|
30
|
-
color 0.4s ease;
|
|
31
21
|
|
|
32
|
-
|
|
22
|
+
body {
|
|
23
|
+
background-color: var(--page-bg, lightgray);
|
|
24
|
+
color: var(--colour-text-default);
|
|
25
|
+
font-family: var(--font-family);
|
|
26
|
+
font-size: var(--step-4);
|
|
27
|
+
/* min-height: 100dvh; */
|
|
28
|
+
transition:
|
|
29
|
+
background-color 0.4s ease,
|
|
30
|
+
color 0.4s ease;
|
|
31
|
+
|
|
32
|
+
overflow-x: clip;
|
|
33
|
+
|
|
34
|
+
#__nuxt {
|
|
35
|
+
height: 100%;
|
|
36
|
+
div {
|
|
37
|
+
.page-layout {
|
|
38
|
+
min-block-size: 100svh;
|
|
39
|
+
display: grid;
|
|
40
|
+
grid-template-rows: auto 1fr auto;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
33
45
|
}
|
|
@@ -379,12 +379,16 @@ useResizeObserver(navRef, () => {
|
|
|
379
379
|
|
|
380
380
|
onClickOutside(navRef, closeMenu);
|
|
381
381
|
|
|
382
|
+
const router = useRouter();
|
|
383
|
+
|
|
382
384
|
onMounted(async () => {
|
|
383
385
|
await nextTick();
|
|
384
386
|
checkOverflow();
|
|
385
387
|
isLoaded.value = true;
|
|
386
|
-
await
|
|
387
|
-
|
|
388
|
+
await router.isReady();
|
|
389
|
+
requestAnimationFrame(() => {
|
|
390
|
+
initNavDecorators();
|
|
391
|
+
});
|
|
388
392
|
});
|
|
389
393
|
|
|
390
394
|
watch(isCollapsed, async (collapsed) => {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<template #actions="{ serviceData }">
|
|
11
11
|
<InputButtonCore
|
|
12
12
|
variant="secondary"
|
|
13
|
-
:button-text="
|
|
13
|
+
:button-text="`${buttonTextPrefix} ${serviceData.title}`"
|
|
14
14
|
:href="`${hrefBase}${serviceData.slug}`"
|
|
15
15
|
:style-class-passthrough="['mbs-24']"
|
|
16
16
|
>
|
|
@@ -42,6 +42,7 @@ interface Props {
|
|
|
42
42
|
eyebrowConfig?: EyebrowConfig;
|
|
43
43
|
heroConfig?: HeroConfig;
|
|
44
44
|
hrefBase?: string;
|
|
45
|
+
buttonTextPrefix?: string;
|
|
45
46
|
styleClassPassthrough?: string | string[];
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -50,6 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
50
51
|
eyebrowConfig: () => ({}),
|
|
51
52
|
heroConfig: () => ({}),
|
|
52
53
|
hrefBase: "/ui/services/services-section/",
|
|
54
|
+
buttonTextPrefix: "Enquire about",
|
|
53
55
|
styleClassPassthrough: () => [],
|
|
54
56
|
});
|
|
55
57
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<div class="info-wrapper" :class="infoWrapperClasses">
|
|
8
8
|
<EyebrowText font-size="large" :text-content="serviceData.subtitle" />
|
|
9
9
|
<HeroText
|
|
10
|
-
tag="
|
|
10
|
+
:tag="headerTag"
|
|
11
11
|
font-size="title"
|
|
12
12
|
:text-content="[
|
|
13
13
|
{
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
<HeroText
|
|
35
35
|
v-if="!isSummary"
|
|
36
|
-
tag="
|
|
36
|
+
:tag="headerTag"
|
|
37
37
|
axis="horizontal"
|
|
38
38
|
font-size="subheading"
|
|
39
39
|
:text-content="serviceData.heroHeading"
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
|
|
48
48
|
<HeroText
|
|
49
49
|
v-if="!isSummary"
|
|
50
|
-
tag="
|
|
50
|
+
:tag="headerTag"
|
|
51
51
|
axis="horizontal"
|
|
52
52
|
font-size="subheading"
|
|
53
53
|
:text-content="[{ text: 'The Process ', styleClass: 'normal' }]"
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
|
|
70
70
|
<HeroText
|
|
71
71
|
v-if="!isSummary"
|
|
72
|
-
tag="
|
|
72
|
+
:tag="headerTag"
|
|
73
73
|
axis="horizontal"
|
|
74
74
|
font-size="subheading"
|
|
75
75
|
:text-content="[{ text: 'Ideal For', styleClass: 'normal' }]"
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
|
|
88
88
|
<HeroText
|
|
89
89
|
v-if="!isSummary"
|
|
90
|
-
tag="
|
|
90
|
+
:tag="headerTag"
|
|
91
91
|
axis="horizontal"
|
|
92
92
|
font-size="subheading"
|
|
93
93
|
:text-content="[{ text: 'Aftercare & Maintenance', styleClass: 'normal' }]"
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
|
|
99
99
|
<HeroText
|
|
100
100
|
v-if="!isSummary"
|
|
101
|
-
tag="
|
|
101
|
+
:tag="headerTag"
|
|
102
102
|
axis="horizontal"
|
|
103
103
|
font-size="subheading"
|
|
104
104
|
:text-content="[{ text: 'Frequently Asked Questions', styleClass: 'normal' }]"
|
|
@@ -144,6 +144,7 @@ import type { Service } from "~/types/types.services";
|
|
|
144
144
|
|
|
145
145
|
interface Props {
|
|
146
146
|
tag?: "div" | "section" | "article" | "main";
|
|
147
|
+
headerTag?: "h1" | "h2" | "h3";
|
|
147
148
|
index?: number;
|
|
148
149
|
serviceData: Service;
|
|
149
150
|
isSummary?: boolean;
|
|
@@ -153,6 +154,7 @@ interface Props {
|
|
|
153
154
|
}
|
|
154
155
|
const props = withDefaults(defineProps<Props>(), {
|
|
155
156
|
tag: "div",
|
|
157
|
+
headerTag: "h2",
|
|
156
158
|
index: 0,
|
|
157
159
|
isSummary: false,
|
|
158
160
|
summaryAlignment: "center",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<component
|
|
3
3
|
:is="tag"
|
|
4
4
|
class="page-hero-highlights"
|
|
5
|
-
:class="[elementClasses, componentClasses
|
|
5
|
+
:class="[elementClasses, componentClasses]"
|
|
6
6
|
:aria-labelledby="ariaLabelledby"
|
|
7
7
|
>
|
|
8
8
|
<div class="header-row">
|
|
@@ -26,7 +26,7 @@ interface Props {
|
|
|
26
26
|
tag?: "div" | "section" | "main";
|
|
27
27
|
highlightsEqualWidths?: boolean;
|
|
28
28
|
highlightsJustify?: "start" | "center" | "end" | "space-between" | "space-around";
|
|
29
|
-
|
|
29
|
+
widthConstrained?: boolean;
|
|
30
30
|
contentAlign?: "start" | "center";
|
|
31
31
|
contentPanel?: boolean;
|
|
32
32
|
highlightTitleBaseline?: boolean;
|
|
@@ -37,22 +37,19 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
37
37
|
tag: "div",
|
|
38
38
|
highlightsEqualWidths: false,
|
|
39
39
|
highlightsJustify: "start",
|
|
40
|
-
|
|
40
|
+
widthConstrained: false,
|
|
41
41
|
contentAlign: "center",
|
|
42
42
|
contentPanel: true,
|
|
43
43
|
highlightTitleBaseline: false,
|
|
44
44
|
styleClassPassthrough: () => [],
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
const gridColumns = computed(() => {
|
|
48
|
-
if (!props.maxWidth) return "16px 1fr 16px";
|
|
49
|
-
if (props.contentAlign === "start") return `16px minmax(0, ${props.maxWidth}) minmax(16px, 1fr)`;
|
|
50
|
-
return `max(16px, (100% - ${props.maxWidth}) / 2) 1fr max(16px, (100% - ${props.maxWidth}) / 2)`;
|
|
51
|
-
});
|
|
52
|
-
|
|
53
47
|
const { headingId, ariaLabelledby } = useAriaLabelledById(() => props.tag);
|
|
54
48
|
const componentClasses = computed(() => ({
|
|
55
49
|
"highlight-title-baseline": props.highlightTitleBaseline,
|
|
50
|
+
[props.contentAlign]: true,
|
|
51
|
+
"width-constrained": props.widthConstrained,
|
|
52
|
+
"has-content-panel": props.contentPanel,
|
|
56
53
|
}));
|
|
57
54
|
|
|
58
55
|
const highlightClasses = computed(() => ({
|
|
@@ -73,7 +70,14 @@ watch(
|
|
|
73
70
|
|
|
74
71
|
<style lang="css">
|
|
75
72
|
.page-hero-highlights {
|
|
73
|
+
/* Layout tokens */
|
|
74
|
+
--max-width: 1064px;
|
|
75
|
+
--page-hero-highlights-gutter-mobile: 16px;
|
|
76
|
+
--page-hero-highlights-gutter-tablet: 40px;
|
|
77
|
+
--page-hero-highlights-gutter-desktop: 32px;
|
|
78
|
+
|
|
76
79
|
/* User themable tokens */
|
|
80
|
+
|
|
77
81
|
--header-row-background-colour: darkblue;
|
|
78
82
|
|
|
79
83
|
--highlights-row-item-gap: 1rem;
|
|
@@ -105,11 +109,46 @@ watch(
|
|
|
105
109
|
--highlight-padding-block-start: 0; /* We're setting the title height via row height, so this should be exposed for override in consuming page */
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
/* Internal layout variables */
|
|
113
|
+
--page-hero-highlights-gutter: var(--page-hero-highlights-gutter-mobile);
|
|
114
|
+
|
|
115
|
+
@container (width >= 768px) {
|
|
116
|
+
--page-hero-highlights-gutter: var(--page-hero-highlights-gutter-tablet);
|
|
117
|
+
}
|
|
118
|
+
@container (width >= 1024px) {
|
|
119
|
+
--page-hero-highlights-gutter: var(--page-hero-highlights-gutter-desktop);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Private geometry */
|
|
123
|
+
--header-slot-grid-row: 1;
|
|
124
|
+
&.highlight-title-baseline {
|
|
125
|
+
--header-slot-grid-row: 1 / span 2;
|
|
126
|
+
}
|
|
127
|
+
|
|
108
128
|
display: grid;
|
|
109
|
-
grid-template-columns: v-bind(gridColumns);
|
|
129
|
+
/* grid-template-columns: v-bind(gridColumns); */
|
|
110
130
|
grid-template-rows: auto var(--highlight-title-height) 1fr auto;
|
|
111
131
|
gap: 0;
|
|
112
132
|
|
|
133
|
+
&.width-constrained {
|
|
134
|
+
grid-template-columns: var(--page-hero-highlights-gutter) 1fr var(--page-hero-highlights-gutter);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
&:not(.width-constrained) {
|
|
138
|
+
&.start {
|
|
139
|
+
grid-template-columns: var(--page-hero-highlights-gutter) minmax(0, var(--max-width)) minmax(
|
|
140
|
+
var(--page-hero-highlights-gutter),
|
|
141
|
+
1fr
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
&.center {
|
|
145
|
+
grid-template-columns: max(var(--page-hero-highlights-gutter), (100% - var(--max-width)) / 2) 1fr max(
|
|
146
|
+
var(--page-hero-highlights-gutter),
|
|
147
|
+
(100% - var(--max-width)) / 2
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
113
152
|
.header-row {
|
|
114
153
|
/* Element geometry */
|
|
115
154
|
grid-column: 1 / -1;
|
|
@@ -123,7 +162,7 @@ watch(
|
|
|
123
162
|
|
|
124
163
|
.header-slot {
|
|
125
164
|
grid-column: 2;
|
|
126
|
-
grid-row:
|
|
165
|
+
grid-row: var(--header-slot-grid-row);
|
|
127
166
|
container-type: inline-size;
|
|
128
167
|
}
|
|
129
168
|
}
|
|
@@ -34,14 +34,24 @@ watch(
|
|
|
34
34
|
<style lang="css">
|
|
35
35
|
.page-hero-highlights-header {
|
|
36
36
|
/* User themable tokens */
|
|
37
|
-
--phh-padding-block: 1.6rem;
|
|
37
|
+
--phh-padding-block-mobile: 1.6rem 3.2rem;
|
|
38
|
+
--phh-padding-block-tablet: 2.4rem 4.8rem;
|
|
39
|
+
--phh-padding-block-desktop: 3.2rem 6.4rem;
|
|
38
40
|
--phh-gap: 1.6rem;
|
|
39
41
|
--phh-end-gap: 0.8rem;
|
|
40
42
|
|
|
41
43
|
display: flex;
|
|
42
44
|
flex-direction: column;
|
|
43
45
|
gap: var(--phh-gap);
|
|
44
|
-
|
|
46
|
+
|
|
47
|
+
padding-block: var(--phh-padding-block-mobile);
|
|
48
|
+
|
|
49
|
+
@container (width >= 768px) {
|
|
50
|
+
padding-block: var(--phh-padding-block-tablet);
|
|
51
|
+
}
|
|
52
|
+
@container (width >= 1024px) {
|
|
53
|
+
padding-block: var(--phh-padding-block-desktop);
|
|
54
|
+
}
|
|
45
55
|
|
|
46
56
|
&:has(.phh-end) {
|
|
47
57
|
@container (width >= 768px) {
|
package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlights.stories.ts
CHANGED
|
@@ -6,7 +6,7 @@ type StoryArgs = {
|
|
|
6
6
|
tag?: "div" | "section" | "main";
|
|
7
7
|
highlightsEqualWidths?: boolean;
|
|
8
8
|
highlightsJustify?: "start" | "center" | "end" | "space-between" | "space-around";
|
|
9
|
-
|
|
9
|
+
widthConstrained?: boolean;
|
|
10
10
|
contentAlign?: "start" | "center";
|
|
11
11
|
contentPanel?: boolean;
|
|
12
12
|
highlightTitleBaseline?: boolean;
|
|
@@ -34,11 +34,10 @@ const meta: Meta<StoryArgs> = {
|
|
|
34
34
|
options: ["start", "center", "end", "space-between", "space-around"],
|
|
35
35
|
description: "Justification of highlight items along the main axis",
|
|
36
36
|
},
|
|
37
|
-
|
|
38
|
-
control:
|
|
39
|
-
options: ["", "600px", "800px", "1024px", "1064px", "1200px", "1440px"],
|
|
37
|
+
widthConstrained: {
|
|
38
|
+
control: "boolean",
|
|
40
39
|
description:
|
|
41
|
-
"
|
|
40
|
+
"When true, caps the central column at --max-width (default 1064px). Gutters grow responsively to enforce the constraint. Override --max-width via styleClassPassthrough to change the cap value.",
|
|
42
41
|
},
|
|
43
42
|
contentAlign: {
|
|
44
43
|
control: { type: "inline-radio" },
|
|
@@ -78,7 +77,7 @@ const meta: Meta<StoryArgs> = {
|
|
|
78
77
|
tag: "div",
|
|
79
78
|
highlightsEqualWidths: false,
|
|
80
79
|
highlightsJustify: "start",
|
|
81
|
-
|
|
80
|
+
widthConstrained: false,
|
|
82
81
|
contentAlign: "center",
|
|
83
82
|
contentPanel: true,
|
|
84
83
|
highlightTitleBaseline: false,
|
|
@@ -119,6 +118,12 @@ All layout and visual properties are customisable via CSS custom properties. Set
|
|
|
119
118
|
|
|
120
119
|
\`\`\`css
|
|
121
120
|
.page-hero-highlights {
|
|
121
|
+
/* Grid layout */
|
|
122
|
+
--max-width: 1064px;
|
|
123
|
+
--page-hero-highlights-gutter-mobile: 16px;
|
|
124
|
+
--page-hero-highlights-gutter-tablet: 40px;
|
|
125
|
+
--page-hero-highlights-gutter-desktop: 32px;
|
|
126
|
+
|
|
122
127
|
/* Header zone */
|
|
123
128
|
--header-row-background-colour: darkblue;
|
|
124
129
|
|
|
@@ -196,6 +201,12 @@ All layout and visual properties are customisable via CSS custom properties. Set
|
|
|
196
201
|
─────────────────────────────────────────────────────────────────── */
|
|
197
202
|
.page-hero-highlights {
|
|
198
203
|
|
|
204
|
+
/* Grid layout */
|
|
205
|
+
--max-width: 1064px;
|
|
206
|
+
--page-hero-highlights-gutter-mobile: 16px;
|
|
207
|
+
--page-hero-highlights-gutter-tablet: 40px;
|
|
208
|
+
--page-hero-highlights-gutter-desktop: 32px;
|
|
209
|
+
|
|
199
210
|
/* Header zone */
|
|
200
211
|
--header-row-background-colour: darkblue;
|
|
201
212
|
|
|
@@ -330,10 +341,10 @@ export const NoSlotContent: Story = {
|
|
|
330
341
|
}),
|
|
331
342
|
};
|
|
332
343
|
|
|
333
|
-
/** Max width centered — content column capped at
|
|
344
|
+
/** Max width centered — content column capped at --max-width (1064px) with equal growing gutters either side. */
|
|
334
345
|
export const MaxWidthCentered: Story = {
|
|
335
346
|
name: "Max Width — Centered",
|
|
336
|
-
args: {
|
|
347
|
+
args: { widthConstrained: true, contentAlign: "center" },
|
|
337
348
|
render: (args: StoryArgs) => ({
|
|
338
349
|
components: { PageHeroHighlights },
|
|
339
350
|
setup() {
|
|
@@ -343,7 +354,7 @@ export const MaxWidthCentered: Story = {
|
|
|
343
354
|
<PageHeroHighlights v-bind="componentArgs" :style="bgStyles">
|
|
344
355
|
<template #header>
|
|
345
356
|
<p class="page-heading-1">Dashboard</p>
|
|
346
|
-
<p class="page-body-normal">Content column is capped at
|
|
357
|
+
<p class="page-body-normal">Content column is capped at --max-width (1064px by default) — gutters grow equally on both sides.</p>
|
|
347
358
|
</template>
|
|
348
359
|
|
|
349
360
|
<template #highlights>
|
|
@@ -370,10 +381,10 @@ export const MaxWidthCentered: Story = {
|
|
|
370
381
|
}),
|
|
371
382
|
};
|
|
372
383
|
|
|
373
|
-
/** Max width start — content column capped at
|
|
384
|
+
/** Max width start — content column capped at --max-width (1064px), pinned to the left. */
|
|
374
385
|
export const MaxWidthStart: Story = {
|
|
375
386
|
name: "Max Width — Start",
|
|
376
|
-
args: {
|
|
387
|
+
args: { widthConstrained: true, contentAlign: "start" },
|
|
377
388
|
render: (args: StoryArgs) => ({
|
|
378
389
|
components: { PageHeroHighlights },
|
|
379
390
|
setup() {
|
|
@@ -383,7 +394,7 @@ export const MaxWidthStart: Story = {
|
|
|
383
394
|
<PageHeroHighlights v-bind="componentArgs" :style="bgStyles">
|
|
384
395
|
<template #header>
|
|
385
396
|
<p class="page-heading-1">Dashboard</p>
|
|
386
|
-
<p class="page-body-normal">Content column is capped at
|
|
397
|
+
<p class="page-body-normal">Content column is capped at --max-width (1064px by default), aligned to the start — right side takes the remaining space.</p>
|
|
387
398
|
</template>
|
|
388
399
|
|
|
389
400
|
<template #highlights>
|
|
@@ -168,39 +168,30 @@ describe("PageHeroHighlights", () => {
|
|
|
168
168
|
expect(el.classes()).toContain("another-class");
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
describe("
|
|
172
|
-
|
|
173
|
-
gridColumns: string;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
it("defaults to fixed 16px gutters with no maxWidth", async () => {
|
|
171
|
+
describe("grid layout classes", () => {
|
|
172
|
+
it("applies center class by default", async () => {
|
|
177
173
|
const wrapper = await mountSuspended(PageHeroHighlights);
|
|
178
|
-
|
|
179
|
-
expect(vm.gridColumns).toBe("16px 1fr 16px");
|
|
174
|
+
expect(wrapper.find(".page-hero-highlights").classes()).toContain("center");
|
|
180
175
|
});
|
|
181
176
|
|
|
182
|
-
it("
|
|
183
|
-
const wrapper = await mountSuspended(PageHeroHighlights
|
|
184
|
-
|
|
185
|
-
});
|
|
186
|
-
const vm = wrapper.vm as unknown as ComponentInstance;
|
|
187
|
-
expect(vm.gridColumns).toBe("max(16px, (100% - 1064px) / 2) 1fr max(16px, (100% - 1064px) / 2)");
|
|
177
|
+
it("does not apply width-constrained class by default", async () => {
|
|
178
|
+
const wrapper = await mountSuspended(PageHeroHighlights);
|
|
179
|
+
expect(wrapper.find(".page-hero-highlights").classes()).not.toContain("width-constrained");
|
|
188
180
|
});
|
|
189
181
|
|
|
190
|
-
it("
|
|
182
|
+
it("applies width-constrained class when widthConstrained is true", async () => {
|
|
191
183
|
const wrapper = await mountSuspended(PageHeroHighlights, {
|
|
192
|
-
props: {
|
|
184
|
+
props: { widthConstrained: true },
|
|
193
185
|
});
|
|
194
|
-
|
|
195
|
-
expect(vm.gridColumns).toBe("16px minmax(0, 1064px) minmax(16px, 1fr)");
|
|
186
|
+
expect(wrapper.find(".page-hero-highlights").classes()).toContain("width-constrained");
|
|
196
187
|
});
|
|
197
188
|
|
|
198
|
-
it("
|
|
189
|
+
it("applies start class when contentAlign is start", async () => {
|
|
199
190
|
const wrapper = await mountSuspended(PageHeroHighlights, {
|
|
200
191
|
props: { contentAlign: "start" },
|
|
201
192
|
});
|
|
202
|
-
|
|
203
|
-
expect(
|
|
193
|
+
expect(wrapper.find(".page-hero-highlights").classes()).toContain("start");
|
|
194
|
+
expect(wrapper.find(".page-hero-highlights").classes()).not.toContain("center");
|
|
204
195
|
});
|
|
205
196
|
});
|
|
206
197
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
2
|
|
|
3
3
|
exports[`PageHeroHighlights > renders correct HTML structure 1`] = `
|
|
4
|
-
"<div class="page-hero-highlights has-content-panel">
|
|
4
|
+
"<div class="page-hero-highlights center has-content-panel">
|
|
5
5
|
<div class="header-row">
|
|
6
6
|
<div class="header-slot">
|
|
7
7
|
<h1>Page Title</h1>
|
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
<template #layout-content>
|
|
5
5
|
<PageHeroHighlights
|
|
6
6
|
tag="section"
|
|
7
|
-
|
|
8
|
-
content-align="
|
|
9
|
-
:content-panel="
|
|
10
|
-
:highlights-equal-widths="
|
|
11
|
-
highlights-justify="
|
|
12
|
-
:highlight-title-baseline="
|
|
7
|
+
:width-constrained="widthConstrained"
|
|
8
|
+
:content-align="contentAlign"
|
|
9
|
+
:content-panel="contentPanel"
|
|
10
|
+
:highlights-equal-widths="highlightsEqualWidths"
|
|
11
|
+
:highlights-justify="highlightsJustify"
|
|
12
|
+
:highlight-title-baseline="highlightTitleBaseline"
|
|
13
13
|
:style-class-passthrough="['mbe-32']"
|
|
14
14
|
>
|
|
15
15
|
<template #header="{ headingId }">
|
|
@@ -38,6 +38,54 @@
|
|
|
38
38
|
<p class="page-heading-2">Recent Activity</p>
|
|
39
39
|
<p class="page-body-normal">Your most recent transactions and events will appear here.</p>
|
|
40
40
|
</div>
|
|
41
|
+
|
|
42
|
+
<div class="demo-controls">
|
|
43
|
+
<p class="demo-controls__heading">Props</p>
|
|
44
|
+
<div class="demo-controls__toggles">
|
|
45
|
+
<ToggleSwitchWithLabelInline
|
|
46
|
+
v-model="widthConstrained"
|
|
47
|
+
name="widthConstrained"
|
|
48
|
+
label="widthConstrained"
|
|
49
|
+
/>
|
|
50
|
+
<ToggleSwitchWithLabelInline v-model="contentPanel" name="contentPanel" label="contentPanel" />
|
|
51
|
+
<ToggleSwitchWithLabelInline
|
|
52
|
+
v-model="highlightsEqualWidths"
|
|
53
|
+
name="highlightsEqualWidths"
|
|
54
|
+
label="highlightsEqualWidths"
|
|
55
|
+
/>
|
|
56
|
+
<ToggleSwitchWithLabelInline
|
|
57
|
+
v-model="highlightTitleBaseline"
|
|
58
|
+
name="highlightTitleBaseline"
|
|
59
|
+
label="highlightTitleBaseline"
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="demo-controls__selects">
|
|
63
|
+
<MultipleRadiobuttons
|
|
64
|
+
v-model="contentAlign"
|
|
65
|
+
v-model:field-data="contentAlignData"
|
|
66
|
+
name="contentAlign"
|
|
67
|
+
legend="contentAlign"
|
|
68
|
+
label="contentAlign"
|
|
69
|
+
error-message=""
|
|
70
|
+
:is-button="true"
|
|
71
|
+
:is-pill="true"
|
|
72
|
+
:field-has-error="false"
|
|
73
|
+
options-layout="inline"
|
|
74
|
+
/>
|
|
75
|
+
<MultipleRadiobuttons
|
|
76
|
+
v-model="highlightsJustify"
|
|
77
|
+
v-model:field-data="highlightsJustifyData"
|
|
78
|
+
name="highlightsJustify"
|
|
79
|
+
legend="highlightsJustify"
|
|
80
|
+
label="highlightsJustify"
|
|
81
|
+
error-message=""
|
|
82
|
+
:is-button="true"
|
|
83
|
+
:is-pill="true"
|
|
84
|
+
:field-has-error="false"
|
|
85
|
+
options-layout="inline"
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
41
89
|
</template>
|
|
42
90
|
</PageHeroHighlights>
|
|
43
91
|
</template>
|
|
@@ -46,6 +94,8 @@
|
|
|
46
94
|
</template>
|
|
47
95
|
|
|
48
96
|
<script setup lang="ts">
|
|
97
|
+
import type { IFormMultipleOptions } from "~/types/forms/types.forms";
|
|
98
|
+
|
|
49
99
|
definePageMeta({
|
|
50
100
|
layout: false,
|
|
51
101
|
});
|
|
@@ -62,13 +112,52 @@ useHead({
|
|
|
62
112
|
class: "page-hero-highlights-page",
|
|
63
113
|
},
|
|
64
114
|
});
|
|
115
|
+
|
|
116
|
+
const widthConstrained = ref(false);
|
|
117
|
+
const contentAlign = ref<"start" | "center">("start");
|
|
118
|
+
const contentPanel = ref(true);
|
|
119
|
+
const highlightsEqualWidths = ref(false);
|
|
120
|
+
const highlightsJustify = ref<"start" | "center" | "end" | "space-between" | "space-around">("start");
|
|
121
|
+
const highlightTitleBaseline = ref(true);
|
|
122
|
+
|
|
123
|
+
const contentAlignData = ref<IFormMultipleOptions>({
|
|
124
|
+
data: [
|
|
125
|
+
{ id: "align-start", name: "contentAlign", value: "start", label: "start" },
|
|
126
|
+
{ id: "align-center", name: "contentAlign", value: "center", label: "center" },
|
|
127
|
+
],
|
|
128
|
+
total: 2,
|
|
129
|
+
skip: 0,
|
|
130
|
+
limit: 2,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const highlightsJustifyData = ref<IFormMultipleOptions>({
|
|
134
|
+
data: [
|
|
135
|
+
{ id: "justify-start", name: "highlightsJustify", value: "start", label: "start" },
|
|
136
|
+
{ id: "justify-center", name: "highlightsJustify", value: "center", label: "center" },
|
|
137
|
+
{ id: "justify-end", name: "highlightsJustify", value: "end", label: "end" },
|
|
138
|
+
{ id: "justify-space-between", name: "highlightsJustify", value: "space-between", label: "space-between" },
|
|
139
|
+
{ id: "justify-space-around", name: "highlightsJustify", value: "space-around", label: "space-around" },
|
|
140
|
+
],
|
|
141
|
+
total: 5,
|
|
142
|
+
skip: 0,
|
|
143
|
+
limit: 5,
|
|
144
|
+
});
|
|
65
145
|
</script>
|
|
66
146
|
|
|
67
147
|
<style lang="css">
|
|
68
148
|
.page-hero-highlights-page {
|
|
69
149
|
.page-hero-highlights {
|
|
150
|
+
/* Layout tokens */
|
|
151
|
+
--max-width: 1064px;
|
|
152
|
+
--page-hero-highlights-gutter-mobile: 16px;
|
|
153
|
+
--page-hero-highlights-gutter-tablet: 40px;
|
|
154
|
+
--page-hero-highlights-gutter-desktop: 32px;
|
|
155
|
+
|
|
70
156
|
.page-hero-highlights-header {
|
|
71
|
-
|
|
157
|
+
/* User themable tokens */
|
|
158
|
+
--phh-padding-block-mobile: 1.6rem 6rem;
|
|
159
|
+
--phh-padding-block-tablet: 2.4rem 6rem;
|
|
160
|
+
--phh-padding-block-desktop: 4.2rem 7.4rem;
|
|
72
161
|
}
|
|
73
162
|
|
|
74
163
|
.content-row {
|
|
@@ -76,10 +165,40 @@ useHead({
|
|
|
76
165
|
color: black;
|
|
77
166
|
|
|
78
167
|
.some-class {
|
|
79
|
-
|
|
168
|
+
padding-block-end: 2.4rem;
|
|
80
169
|
}
|
|
81
170
|
}
|
|
82
171
|
}
|
|
83
172
|
}
|
|
173
|
+
|
|
174
|
+
.demo-controls {
|
|
175
|
+
margin-block-start: 2.4rem;
|
|
176
|
+
padding: 1.6rem;
|
|
177
|
+
border: 1px solid var(--slate-06);
|
|
178
|
+
border-radius: 0.8rem;
|
|
179
|
+
background: var(--slate-01);
|
|
180
|
+
|
|
181
|
+
&__heading {
|
|
182
|
+
font-size: 1.1rem;
|
|
183
|
+
font-weight: 600;
|
|
184
|
+
text-transform: uppercase;
|
|
185
|
+
letter-spacing: 0.08em;
|
|
186
|
+
color: var(--slate-08);
|
|
187
|
+
margin-block-end: 1.2rem;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
&__toggles {
|
|
191
|
+
display: flex;
|
|
192
|
+
flex-wrap: wrap;
|
|
193
|
+
gap: 0.4rem 2.4rem;
|
|
194
|
+
margin-block-end: 1.6rem;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
&__selects {
|
|
198
|
+
display: flex;
|
|
199
|
+
flex-direction: column;
|
|
200
|
+
gap: 0.8rem;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
84
203
|
}
|
|
85
204
|
</style>
|