srcdev-nuxt-components 9.6.1 → 9.6.3
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/rules/consumer/consumer-overrides.md +17 -0
- package/.claude/rules/consumer/css-comment-slash.md +13 -0
- package/.claude/rules/consumer/css-native-nesting.md +13 -0
- package/.claude/rules/consumer/css-no-deep.md +13 -0
- package/.claude/rules/consumer/css-rem-base.md +11 -0
- package/.claude/rules/consumer/css-surface-text-pairing.md +13 -0
- package/.claude/rules/consumer/css-webkit-support.md +15 -0
- package/.claude/rules/consumer/explicit-vue-imports.md +11 -0
- package/.claude/rules/consumer/nuxt-img-dimensions.md +12 -0
- package/.claude/rules/consumer/srcdev-skills-location.md +9 -0
- package/.claude/rules/consumer/story-args.md +12 -0
- package/.claude/rules/consumer/story-register-components.md +12 -0
- package/.claude/rules/consumer/vue-auto-imports.md +14 -0
- package/.claude/rules/consumer/vue-no-self-closing.md +11 -0
- package/.claude/rules/consumer/vue-prop-hyphenation.md +11 -0
- package/.claude/rules/consumer/vue-props-pattern.md +13 -0
- package/.claude/rules/consumer/vue-slots-no-computed.md +14 -0
- package/.claude/rules/consumer/vue-video-source.md +12 -0
- package/.claude/skills/component-compliance-checklist.md +37 -2
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/display-avatar.md +14 -3
- package/.claude/skills/components/display-chip.md +2 -1
- package/.claude/skills/components/google-reviews.md +2 -2
- package/.claude/skills/components/pricing-card.md +68 -84
- package/.claude/skills/components/profile-section.md +59 -21
- package/.claude/skills/components/social-icons-list.md +2 -0
- package/.claude/skills/index.md +13 -6
- package/.claude/skills/new-app-scaffold.md +4 -2
- package/.claude/skills/setup-postinstall.md +5 -4
- package/.claude/skills/storybook-add-story.md +38 -0
- package/.claude/skills/theming-component-token-pattern.md +94 -0
- package/.claude/skills/theming-partial-override.md +1 -1
- package/.vscode/srcdev-component-display-avatar.code-snippets +7 -0
- package/.vscode/srcdev-component-pricing-card.code-snippets +46 -84
- package/.vscode/srcdev-component-profile-section.code-snippets +27 -4
- package/README.md +5 -5
- package/app/components/01.atoms/animations/scroll-reveal-frame/stories/ScrollRevealFrame.stories.ts +2 -0
- package/app/components/01.atoms/animations/scroll-reveal-image/stories/ScrollRevealImage.stories.ts +2 -0
- package/app/components/01.atoms/animations/section-parallax/stories/SectionParallax.stories.ts +4 -0
- package/app/components/01.atoms/animations/view-timeline/stories/WipeAwayVertical.stories.ts +2 -0
- package/app/components/02.molecules/cookie-consent-banner/stories/CookieConsentBanner.stories.ts +2 -0
- package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +26 -10
- package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +75 -34
- package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +84 -3
- package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +85 -13
- package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +3 -3
- package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +7 -2
- package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +0 -1
- package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +96 -149
- package/app/components/02.molecules/pricing-card/PricingCard.vue +84 -44
- package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +241 -104
- package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +149 -123
- package/app/components/02.molecules/profile-section/CONSUMER-STYLING.md +103 -13
- package/app/components/02.molecules/profile-section/ProfileSection.vue +123 -23
- package/app/components/02.molecules/profile-section/stories/ProfileSection.stories.ts +353 -115
- package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +144 -11
- package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap +6 -11
- package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +6 -2
- package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +1 -0
- package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +4 -0
- package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +4 -0
- package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +1 -1
- package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +6 -6
- package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabel.vue +1 -1
- package/app/composables/useAriaDescribedById.ts +1 -1
- package/package.json +2 -1
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
3
|
import PricingCard from "../PricingCard.vue";
|
|
4
4
|
|
|
5
|
+
const baseProps = { planName: "Test Plan", price: 100 };
|
|
6
|
+
|
|
5
7
|
describe("PricingCard", () => {
|
|
6
|
-
it("renders plan name, price, and CTA", () => {
|
|
7
|
-
const wrapper =
|
|
8
|
-
props: {
|
|
9
|
-
planName: "Single Site",
|
|
10
|
-
price: 99,
|
|
11
|
-
billingPeriod: "one-time",
|
|
12
|
-
ctaText: "Get started",
|
|
13
|
-
},
|
|
8
|
+
it("renders plan name, price, and CTA", async () => {
|
|
9
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
10
|
+
props: { planName: "Single Site", price: 99, billingPeriod: "one-time", ctaText: "Get started" },
|
|
14
11
|
});
|
|
15
12
|
|
|
16
13
|
expect(wrapper.text()).toContain("Single Site");
|
|
@@ -19,173 +16,202 @@ describe("PricingCard", () => {
|
|
|
19
16
|
expect(wrapper.text()).toContain("Get started");
|
|
20
17
|
});
|
|
21
18
|
|
|
22
|
-
it("uses custom currency symbol when provided", () => {
|
|
23
|
-
const wrapper =
|
|
24
|
-
props: {
|
|
25
|
-
planName: "Single Site",
|
|
26
|
-
price: 49,
|
|
27
|
-
currencySymbol: "£",
|
|
28
|
-
},
|
|
19
|
+
it("uses custom currency symbol when provided", async () => {
|
|
20
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
21
|
+
props: { planName: "Single Site", price: 49, currencySymbol: "£" },
|
|
29
22
|
});
|
|
30
23
|
|
|
31
24
|
expect(wrapper.text()).toContain("£49");
|
|
32
25
|
expect(wrapper.text()).not.toContain("$49");
|
|
33
26
|
});
|
|
34
27
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
28
|
+
describe("price display", () => {
|
|
29
|
+
it("shows whole numbers without decimals", async () => {
|
|
30
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, price: 1500 } });
|
|
31
|
+
expect(wrapper.find(".pricing-card__amount").text()).toBe("$1500");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("shows fractional prices to two decimal places", async () => {
|
|
35
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, price: 9.5 } });
|
|
36
|
+
expect(wrapper.find(".pricing-card__amount").text()).toBe("$9.50");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps zero and negative prices", async () => {
|
|
40
|
+
const zero = await mountSuspended(PricingCard, { props: { ...baseProps, price: 0 } });
|
|
41
|
+
expect(zero.find(".pricing-card__amount").text()).toBe("$0");
|
|
42
|
+
|
|
43
|
+
const negative = await mountSuspended(PricingCard, { props: { ...baseProps, price: -20 } });
|
|
44
|
+
expect(negative.find(".pricing-card__amount").text()).toBe("$-20");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("shows only the currency symbol for a non-finite price", async () => {
|
|
48
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, price: Number.NaN } });
|
|
49
|
+
expect(wrapper.find(".pricing-card__amount").text()).toBe("$");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("updates when the price prop changes", async () => {
|
|
53
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, price: 10 } });
|
|
54
|
+
await wrapper.setProps({ price: 12.25 });
|
|
55
|
+
expect(wrapper.find(".pricing-card__amount").text()).toBe("$12.25");
|
|
43
56
|
});
|
|
57
|
+
});
|
|
44
58
|
|
|
59
|
+
it("renders features from props", async () => {
|
|
60
|
+
const features = ["Feature 1", "Feature 2", "Feature 3"];
|
|
61
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, features } });
|
|
62
|
+
|
|
63
|
+
expect(wrapper.findAll(".pricing-card__feature")).toHaveLength(3);
|
|
45
64
|
features.forEach((feature) => {
|
|
46
65
|
expect(wrapper.text()).toContain(feature);
|
|
47
66
|
});
|
|
48
67
|
});
|
|
49
68
|
|
|
50
|
-
it("renders
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
props: {
|
|
54
|
-
planName: "Test Plan",
|
|
55
|
-
price: 100,
|
|
56
|
-
description,
|
|
57
|
-
},
|
|
69
|
+
it("renders HTML-like text as text", async () => {
|
|
70
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
71
|
+
props: { ...baseProps, planName: "<b>Bold</b>", features: ["<script>alert(1)</script>"] },
|
|
58
72
|
});
|
|
59
73
|
|
|
60
|
-
expect(wrapper.
|
|
74
|
+
expect(wrapper.find(".pricing-card__name b").exists()).toBe(false);
|
|
75
|
+
expect(wrapper.find(".pricing-card__name").text()).toBe("<b>Bold</b>");
|
|
76
|
+
expect(wrapper.find("script").exists()).toBe(false);
|
|
61
77
|
});
|
|
62
78
|
|
|
63
|
-
it("
|
|
64
|
-
const
|
|
65
|
-
props: {
|
|
66
|
-
planName: "Test Plan",
|
|
67
|
-
price: 100,
|
|
68
|
-
isHighlighted: true,
|
|
69
|
-
},
|
|
79
|
+
it("renders description when provided and omits it otherwise", async () => {
|
|
80
|
+
const withDescription = await mountSuspended(PricingCard, {
|
|
81
|
+
props: { ...baseProps, description: "This is a great plan" },
|
|
70
82
|
});
|
|
83
|
+
expect(withDescription.find(".pricing-card__description").text()).toBe("This is a great plan");
|
|
84
|
+
|
|
85
|
+
const without = await mountSuspended(PricingCard, { props: baseProps });
|
|
86
|
+
expect(without.find(".pricing-card__description").exists()).toBe(false);
|
|
87
|
+
});
|
|
71
88
|
|
|
72
|
-
|
|
89
|
+
it("omits the billing period when empty", async () => {
|
|
90
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, billingPeriod: "" } });
|
|
91
|
+
expect(wrapper.find(".pricing-card__period").exists()).toBe(false);
|
|
73
92
|
});
|
|
74
93
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
props: {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
},
|
|
94
|
+
describe("highlight", () => {
|
|
95
|
+
it("applies highlighted class and default badge", async () => {
|
|
96
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, isHighlighted: true } });
|
|
97
|
+
|
|
98
|
+
expect(wrapper.classes()).toContain("is-highlighted");
|
|
99
|
+
expect(wrapper.find(".pricing-card__badge").text()).toBe("Most Popular");
|
|
82
100
|
});
|
|
83
101
|
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
it("renders custom badge text", async () => {
|
|
103
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
104
|
+
props: { ...baseProps, isHighlighted: true, badgeText: "Le plus populaire" },
|
|
105
|
+
});
|
|
86
106
|
|
|
87
|
-
|
|
88
|
-
const wrapper = mount(PricingCard, {
|
|
89
|
-
props: {
|
|
90
|
-
planName: "Test Plan",
|
|
91
|
-
price: 100,
|
|
92
|
-
isHighlighted: true,
|
|
93
|
-
badgeText: "Le plus populaire",
|
|
94
|
-
},
|
|
107
|
+
expect(wrapper.find(".pricing-card__badge").text()).toBe("Le plus populaire");
|
|
95
108
|
});
|
|
96
109
|
|
|
97
|
-
|
|
110
|
+
it("has no badge when not highlighted", async () => {
|
|
111
|
+
const wrapper = await mountSuspended(PricingCard, { props: baseProps });
|
|
112
|
+
expect(wrapper.find(".pricing-card__badge").exists()).toBe(false);
|
|
113
|
+
expect(wrapper.classes()).not.toContain("is-highlighted");
|
|
114
|
+
});
|
|
98
115
|
});
|
|
99
116
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
props:
|
|
103
|
-
planName: "Test Plan",
|
|
104
|
-
price: 100,
|
|
105
|
-
},
|
|
106
|
-
});
|
|
117
|
+
describe("CTA", () => {
|
|
118
|
+
it("emits select event with plan name on click", async () => {
|
|
119
|
+
const wrapper = await mountSuspended(PricingCard, { props: baseProps });
|
|
107
120
|
|
|
108
|
-
|
|
109
|
-
await button.trigger("click");
|
|
121
|
+
await wrapper.find("button").trigger("click");
|
|
110
122
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
});
|
|
123
|
+
expect(wrapper.emitted("select")?.[0]).toEqual(["Test Plan"]);
|
|
124
|
+
});
|
|
114
125
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
},
|
|
126
|
+
it("disables CTA when ctaDisabled is true", async () => {
|
|
127
|
+
const wrapper = await mountSuspended(PricingCard, { props: { ...baseProps, ctaDisabled: true } });
|
|
128
|
+
|
|
129
|
+
const button = wrapper.find("button");
|
|
130
|
+
expect(button.attributes("readonly")).toBeDefined();
|
|
131
|
+
expect(button.attributes("aria-disabled")).toBe("true");
|
|
122
132
|
});
|
|
123
133
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
it("passes scope to the cta slot", async () => {
|
|
135
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
136
|
+
props: { ...baseProps, ctaText: "Choose", ctaDisabled: true },
|
|
137
|
+
slots: {
|
|
138
|
+
cta: `<template #cta="{ ctaText, isDisabled, planName, onSelect }">
|
|
139
|
+
<button class="custom-cta" :data-disabled="isDisabled" :data-plan="planName" @click="onSelect">{{ ctaText }}</button>
|
|
140
|
+
</template>`,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const button = wrapper.find(".custom-cta");
|
|
145
|
+
expect(button.text()).toBe("Choose");
|
|
146
|
+
expect(button.attributes("data-disabled")).toBe("true");
|
|
147
|
+
expect(button.attributes("data-plan")).toBe("Test Plan");
|
|
148
|
+
await button.trigger("click");
|
|
149
|
+
expect(wrapper.emitted("select")?.[0]).toEqual(["Test Plan"]);
|
|
150
|
+
});
|
|
127
151
|
});
|
|
128
152
|
|
|
129
|
-
it("renders custom features via slot", () => {
|
|
130
|
-
const wrapper =
|
|
131
|
-
props:
|
|
132
|
-
|
|
133
|
-
price: 100,
|
|
134
|
-
},
|
|
135
|
-
slots: {
|
|
136
|
-
features: '<li>Custom Feature 1</li><li>Custom Feature 2</li>',
|
|
137
|
-
},
|
|
153
|
+
it("renders custom features via slot", async () => {
|
|
154
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
155
|
+
props: baseProps,
|
|
156
|
+
slots: { features: "<li>Custom Feature 1</li><li>Custom Feature 2</li>" },
|
|
138
157
|
});
|
|
139
158
|
|
|
140
159
|
expect(wrapper.text()).toContain("Custom Feature 1");
|
|
141
160
|
expect(wrapper.text()).toContain("Custom Feature 2");
|
|
142
161
|
});
|
|
143
162
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
props:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
163
|
+
describe("semantics", () => {
|
|
164
|
+
it("uses an article with an h3 by default", async () => {
|
|
165
|
+
const wrapper = await mountSuspended(PricingCard, { props: baseProps });
|
|
166
|
+
|
|
167
|
+
expect(wrapper.element.tagName).toBe("ARTICLE");
|
|
168
|
+
expect(wrapper.find(".pricing-card__name").element.tagName).toBe("H3");
|
|
150
169
|
});
|
|
151
170
|
|
|
152
|
-
|
|
153
|
-
|
|
171
|
+
it("allows custom tag and heading level", async () => {
|
|
172
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
173
|
+
props: { ...baseProps, tag: "section", headingTag: "h2" },
|
|
174
|
+
});
|
|
154
175
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
props: {
|
|
158
|
-
planName: "Test Plan",
|
|
159
|
-
price: 100,
|
|
160
|
-
tag: "section",
|
|
161
|
-
},
|
|
176
|
+
expect(wrapper.element.tagName).toBe("SECTION");
|
|
177
|
+
expect(wrapper.find(".pricing-card__name").element.tagName).toBe("H2");
|
|
162
178
|
});
|
|
163
179
|
|
|
164
|
-
|
|
180
|
+
it("labels the root with the plan name heading", async () => {
|
|
181
|
+
const wrapper = await mountSuspended(PricingCard, { props: baseProps });
|
|
182
|
+
|
|
183
|
+
const headingId = wrapper.find(".pricing-card__name").attributes("id");
|
|
184
|
+
expect(headingId).toBeTruthy();
|
|
185
|
+
expect(wrapper.attributes("aria-labelledby")).toBe(headingId);
|
|
186
|
+
});
|
|
165
187
|
});
|
|
166
188
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
props:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
describe("ribbon", () => {
|
|
190
|
+
it("does not render a ribbon when ribbonText is unset", async () => {
|
|
191
|
+
const wrapper = await mountSuspended(PricingCard, { props: baseProps });
|
|
192
|
+
|
|
193
|
+
expect(wrapper.find(".pricing-card__ribbon-clip").exists()).toBe(false);
|
|
194
|
+
expect(wrapper.classes()).not.toContain("has-ribbon");
|
|
173
195
|
});
|
|
174
196
|
|
|
175
|
-
|
|
197
|
+
it("renders ribbon text and flags the root when ribbonText is provided", async () => {
|
|
198
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
199
|
+
props: { ...baseProps, ribbonText: "EARLYBIRD50 · 50% off" },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(wrapper.find(".pricing-card__ribbon").text()).toBe("EARLYBIRD50 · 50% off");
|
|
203
|
+
expect(wrapper.classes()).toContain("has-ribbon");
|
|
204
|
+
});
|
|
176
205
|
});
|
|
177
206
|
|
|
178
|
-
it("
|
|
179
|
-
const wrapper =
|
|
180
|
-
props: {
|
|
181
|
-
planName: "Test Plan",
|
|
182
|
-
price: 100,
|
|
183
|
-
ribbonText: "EARLYBIRD50 · 50% off",
|
|
184
|
-
},
|
|
207
|
+
it("applies and updates styleClassPassthrough classes", async () => {
|
|
208
|
+
const wrapper = await mountSuspended(PricingCard, {
|
|
209
|
+
props: { ...baseProps, styleClassPassthrough: ["custom-a"] },
|
|
185
210
|
});
|
|
211
|
+
expect(wrapper.classes()).toContain("custom-a");
|
|
186
212
|
|
|
187
|
-
|
|
188
|
-
expect(
|
|
189
|
-
expect(
|
|
213
|
+
await wrapper.setProps({ styleClassPassthrough: ["custom-b"] });
|
|
214
|
+
expect(wrapper.classes()).toContain("custom-b");
|
|
215
|
+
expect(wrapper.classes()).not.toContain("custom-a");
|
|
190
216
|
});
|
|
191
217
|
});
|
|
@@ -2,35 +2,89 @@
|
|
|
2
2
|
|
|
3
3
|
## Public token API
|
|
4
4
|
|
|
5
|
+
### Grid and picture
|
|
6
|
+
|
|
5
7
|
| Token | Default | Controls |
|
|
6
8
|
|---|---|---|
|
|
7
|
-
| `--profile-section-gap` | `2rem` | Gap between picture and info, single column (
|
|
8
|
-
| `--profile-section-gap-wide` | `4rem` | Gap between picture and info columns (768px
|
|
9
|
-
| `--profile-section-picture-width` | `
|
|
9
|
+
| `--profile-section-gap` | `2rem` | Gap between picture and info, single column (component under 768px wide) |
|
|
10
|
+
| `--profile-section-gap-wide` | `4rem` | Gap between picture and info columns (component 768px wide or more) |
|
|
11
|
+
| `--profile-section-picture-width` | `38.4rem` | Maximum picture column width in the two-column layout. The column can shrink below it |
|
|
10
12
|
| `--profile-section-picture-aspect-ratio` | `3 / 4` | Picture frame aspect ratio |
|
|
11
|
-
| `--profile-section-picture-border-radius` | `
|
|
13
|
+
| `--profile-section-picture-border-radius` | `0.8rem` | Picture frame corner rounding |
|
|
12
14
|
| `--profile-section-info-block-gap` | `1.5rem` | Space after each `profile-info-N` block |
|
|
13
15
|
| `--profile-section-links-gap` | `1rem` | Gap between items in the `profileLinks` slot |
|
|
16
|
+
| `--profile-section-info-text-colour` | `inherit` | Text colour of the info column (blocks and links) |
|
|
17
|
+
|
|
18
|
+
### Info block highlights
|
|
19
|
+
|
|
20
|
+
| Token | Default | Controls |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `--profile-section-highlight-colour` | `var(--colour-text-accent)` | `.highlight` inside a `.location` element |
|
|
23
|
+
| `--profile-section-link-colour` | `var(--colour-link-default)` | `.highlight` inside a `.services` element |
|
|
24
|
+
| `--profile-section-link-colour-hover` | `var(--colour-link-hover)` | The same, on hover |
|
|
25
|
+
| `--profile-section-highlight-font-weight` | `600` | Weight of both highlight kinds (also sets the `wght` variation axis) |
|
|
26
|
+
|
|
27
|
+
The defaults read the global `--colour-*` tokens, so setting those on an ancestor still works.
|
|
14
28
|
|
|
15
|
-
|
|
16
|
-
`--colour-link-default` and `--colour-link-hover` (see **State hooks**).
|
|
29
|
+
### Header in picture
|
|
17
30
|
|
|
31
|
+
These only apply when `header-placement` is `picture-bottom` or `picture-top`. The scrim fades
|
|
32
|
+
upward from the bottom edge for `picture-bottom` and downward from the top edge for `picture-top`.
|
|
33
|
+
|
|
34
|
+
| Token | Default | Controls |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `--profile-section-picture-header-scrim-colour` | `#000` | Colour the scrim starts from at the frame's edge |
|
|
37
|
+
| `--profile-section-picture-header-scrim-opacity` | `75%` | Opacity of the scrim colour at its start |
|
|
38
|
+
| `--profile-section-picture-header-scrim-solid-stop` | `0%` | How far into the header the scrim stays at full strength before it starts to fade (e.g. `55%` keeps it solid behind the text) |
|
|
39
|
+
| `--profile-section-picture-header-scrim-target-colour` | `transparent` | Colour the scrim fades into at the header's inner edge. Set it to roughly the photo's colour there (often translucent) so the fade blends into the image instead of leaving a grey band or a visible edge |
|
|
40
|
+
| `--profile-section-picture-header-scrim` | gradient from the scrim colour | The whole scrim `background`; set `none` to remove it |
|
|
41
|
+
| `--profile-section-picture-header-text-colour` | `contrast-color(<scrim colour>)`, `#fff` without support | Eyebrow and heading text colour |
|
|
42
|
+
| `--profile-section-picture-header-eyebrow-colour` | `inherit` | Eyebrow colour only |
|
|
43
|
+
| `--profile-section-picture-header-padding` | `2.4rem` | Padding around the header inside the picture |
|
|
44
|
+
| `--profile-section-picture-header-gap` | `0.4rem` | Gap between eyebrow and heading |
|
|
45
|
+
|
|
46
|
+
Matching the photo is a judgement call per image: pick the target colour from the part of the photo
|
|
47
|
+
the fade ends on, and add end padding (`--profile-section-picture-header-padding`, e.g.
|
|
48
|
+
`3.2rem 3.2rem 6.4rem` for `picture-top`) so the fade sits past the text. The header only rounds the
|
|
49
|
+
corners on the frame's edge, so an opaque target colour doesn't show rounded inner corners.
|
|
50
|
+
Browsers without `color-mix()` (before Safari 16.2) start the scrim from 75% black whatever the scrim
|
|
51
|
+
colour and opacity; the solid stop and target colour still apply.
|
|
52
|
+
|
|
53
|
+
`contrast-color()` only reads the scrim colour, not the photo. If you remove the scrim, set
|
|
54
|
+
`--profile-section-picture-header-text-colour` yourself to suit the image. The eyebrow's own
|
|
55
|
+
`--eyebrow-text-bg-img` is set to `none` inside the picture.
|
|
56
|
+
|
|
57
|
+
> Changed 2026-10-14: new `header-placement` prop and its tokens, and `--profile-section-info-text-colour`. The scrim
|
|
58
|
+
> gained opacity, solid-stop and target-colour tokens. The image is now absolutely positioned
|
|
59
|
+
> inside the frame (no visual change), so a header in the frame can grow it past its aspect ratio.
|
|
60
|
+
>
|
|
61
|
+
> Changed 2026-10-14: the highlight colours read the global `--colour-*` tokens directly; they now
|
|
62
|
+
> go through the `--profile-section-*` tokens above, with the same defaults. `384px` and `8px`
|
|
63
|
+
> became the same values in rem.
|
|
64
|
+
>
|
|
18
65
|
> Changed 2026-09-27: all of these were hardcoded values; they're now tokens with the same
|
|
19
66
|
> defaults.
|
|
20
67
|
|
|
21
|
-
|
|
68
|
+
Private tokens (not public API): `--_scrim-colour`, the resolved scrim colour used by the gradient
|
|
69
|
+
and `contrast-color()`, `--_scrim-direction` (`to top` or `to bottom`, set by the placement), and
|
|
70
|
+
`--_scrim-start-colour`, `--_scrim-solid-stop` and `--_scrim-target-colour`, which compose the
|
|
71
|
+
gradient from the tokens above.
|
|
22
72
|
|
|
23
73
|
---
|
|
24
74
|
|
|
25
75
|
## State hooks
|
|
26
76
|
|
|
27
|
-
Inner classes: `.profile-section-header
|
|
28
|
-
`.profile-section-picture
|
|
29
|
-
|
|
30
|
-
|
|
77
|
+
Inner classes: `.profile-section-header` (only rendered when the `eyebrowText` or `heroText` slot
|
|
78
|
+
is given; it moves inside `.profile-section-picture`, before the image, when `header-placement` is
|
|
79
|
+
`picture-bottom` or `picture-top`), `.profile-section-inner` (the grid), `.profile-section-picture` (frame),
|
|
80
|
+
`.profile-section-image` (the `NuxtImg`), `.profile-section-info`,
|
|
81
|
+
`.profile-section-info-content`, `.profile-section-info-block` (one per `profile-info-N` slot
|
|
82
|
+
given), `.profile-section-links`.
|
|
31
83
|
|
|
32
|
-
|
|
33
|
-
|
|
84
|
+
`data-header-placement` on the root: `above`, `picture-bottom` or `picture-top`.
|
|
85
|
+
|
|
86
|
+
Content hooks inside info blocks: a `.highlight` inside an element with class `location` or
|
|
87
|
+
`services` gets the highlight or link colour above.
|
|
34
88
|
|
|
35
89
|
> Changed 2026-09-27: renamed from `.picture`, `.profile-picture`, `.profile-info`,
|
|
36
90
|
> `.profile-info-content`, `.profile-info-block` and `.profile-links`. `.picture` in particular
|
|
@@ -38,6 +92,35 @@ Content hooks inside info blocks: an element with class `location` or `services`
|
|
|
38
92
|
|
|
39
93
|
---
|
|
40
94
|
|
|
95
|
+
## Layout
|
|
96
|
+
|
|
97
|
+
The root is a size container named `profile-section` (`container: profile-section / inline-size`).
|
|
98
|
+
The grid switches to two columns when **the component itself** is 768px wide or more, not the
|
|
99
|
+
viewport, so it stays stacked in a narrow column or sidebar. Because it's an inline-size container,
|
|
100
|
+
its width can't come from its content: in a flex row or other shrink-to-fit context, give it a
|
|
101
|
+
width (`flex: 1`, `width: 100%`) or it collapses.
|
|
102
|
+
|
|
103
|
+
Both grid columns are `minmax(0, …)`, so long unbroken text wraps (the root sets
|
|
104
|
+
`overflow-wrap: anywhere`) instead of widening the grid. Profile links wrap onto more lines.
|
|
105
|
+
|
|
106
|
+
To target the two-column layout in your own CSS, query the same container:
|
|
107
|
+
|
|
108
|
+
```css
|
|
109
|
+
.my-page .profile-section-inner {
|
|
110
|
+
@container profile-section (min-width: 768px) {
|
|
111
|
+
align-items: center;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
> Changed 2026-10-14: the breakpoint was a viewport `@media (min-width: 768px)` query. A
|
|
117
|
+
> `@media (min-width: 768px)` override of `.profile-section-inner`'s `grid-template-columns` now
|
|
118
|
+
> applies at a different point from the component's own switch. Remove it and set
|
|
119
|
+
> `--profile-section-picture-width` instead (`minmax(0, 320px) 1fr` is
|
|
120
|
+
> `--profile-section-picture-width: 32rem`, since the column is now `minmax(0, …)` by default).
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
41
124
|
## Local overrides
|
|
42
125
|
|
|
43
126
|
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
@@ -51,3 +134,10 @@ Set the tokens above on an element you own (a page or section class, or a class
|
|
|
51
134
|
## Class passthrough
|
|
52
135
|
|
|
53
136
|
`style-class-passthrough` adds classes to the root element. Reactive after mount.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Notes
|
|
141
|
+
|
|
142
|
+
There are no line-clamp tokens. All text comes through slots, so it is your own markup: clamp it
|
|
143
|
+
there, for example with `-webkit-line-clamp` on your own paragraph class.
|