srcdev-nuxt-components 9.2.7 → 9.2.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/commands/create-commit-message.md +13 -0
- package/.claude/commands/run-commit.md +13 -0
- package/.claude/settings.json +6 -2
- package/.claude/skills/index.md +3 -2
- package/.claude/skills/theming-form-geometry-tokens.md +71 -0
- package/.claude/skills/theming-partial-override.md +19 -15
- package/.vscode/srcdev-component-pricing-card.code-snippets +24 -3
- package/app/assets/styles/setup/03.theming/_theme-slots.css +6 -1
- package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +7 -2
- package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +28 -7
- package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +1 -0
- package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +29 -0
- package/app/components/02.molecules/pricing-card/PricingCard.vue +45 -14
- package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +21 -0
- package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +25 -0
- package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +4 -1
- package/app/components/05.forms/input-button/InputButtonCore.vue +11 -1
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +1 -0
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioWithLabel.vue +1 -0
- package/package.json +1 -1
- /package/.claude/skills/{release-notes.md → create-release-notes.md} +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Draft a commit message from staged changes only — never runs git add/commit/push
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Draft a commit message for the currently staged changes. Do not run `git add`, `git commit`, or `git push` — this command only produces the message text for review.
|
|
6
|
+
|
|
7
|
+
Steps:
|
|
8
|
+
1. Run `git status` and `git diff --staged` to see what's staged. If nothing is staged, say so and stop.
|
|
9
|
+
2. Run `git log --oneline -10` to match this repo's existing commit message style (e.g. `type(scope): summary`).
|
|
10
|
+
3. Draft a concise message: a short summary line, plus a body only if the change needs context not obvious from the diff. Focus on *why*, not a restatement of the diff.
|
|
11
|
+
4. Present the message in a fenced code block. Do not commit it.
|
|
12
|
+
|
|
13
|
+
If the user's request also explicitly says "commit this" or "run it", that's authorization to act — use the `/run-commit` flow instead of stopping at the draft.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Stage named files, commit, and push — the full flow (explicit authorization to act)
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Run the full commit flow: stage, commit, and push. Unlike `/create-commit-message`, this command is explicit authorization to execute git commands, not just draft text.
|
|
6
|
+
|
|
7
|
+
Steps:
|
|
8
|
+
1. Run `git status`, `git diff`, and `git diff --staged` to see the full picture before staging anything.
|
|
9
|
+
2. Stage files by name (never `git add -A` or `git add .`) so nothing unexpected — stray build output, `.env`, credentials — gets swept in. Confirm with the user if it's unclear which changed files belong in this commit.
|
|
10
|
+
3. Draft the commit message following the repo's existing style (`git log --oneline -10`), and create the commit with a HEREDOC so formatting is preserved, ending with the `Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>` trailer.
|
|
11
|
+
4. Push to the current branch's tracked remote. If the branch has no upstream yet, ask before setting one with `-u`. Never force-push without explicit user instruction.
|
|
12
|
+
|
|
13
|
+
Never use `--no-verify`, `--no-gpg-sign`, or otherwise skip hooks/signing. If a pre-commit hook fails, fix the underlying issue and create a **new** commit — never amend to work around a failed hook.
|
package/.claude/settings.json
CHANGED
|
@@ -93,12 +93,16 @@
|
|
|
93
93
|
"Bash(SRCDEV_STANDALONE=true npx playwright test app/components/01.atoms/content-wrappers/docs-pages/playwright/content-docs.playwright.ts --project=chromium)",
|
|
94
94
|
"Bash(git rm *)",
|
|
95
95
|
"Bash(awk '{print $1}')",
|
|
96
|
-
"Bash(git reset *)"
|
|
96
|
+
"Bash(git reset *)",
|
|
97
|
+
"Read(//Users/simoncornforth/.claude/**)",
|
|
98
|
+
"Bash(git push *)",
|
|
99
|
+
"Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('.vscode/srcdev-component-pricing-card.code-snippets','utf8'\\)\\); console.log\\('valid json'\\)\")"
|
|
97
100
|
],
|
|
98
101
|
"additionalDirectories": [
|
|
99
102
|
"/Users/simoncornforth/websites/instepreflexology",
|
|
100
103
|
"/Users/simoncornforth/websites/nuxt-components/.vscode",
|
|
101
|
-
"/Users/simoncornforth/websites/nuxt-components/.claude/skills/components"
|
|
104
|
+
"/Users/simoncornforth/websites/nuxt-components/.claude/skills/components",
|
|
105
|
+
"/Users/simoncornforth/websites/nuxt-components/.claude/skills"
|
|
102
106
|
]
|
|
103
107
|
}
|
|
104
108
|
}
|
package/.claude/skills/index.md
CHANGED
|
@@ -30,7 +30,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
30
30
|
├── theming-colour-ramps.md — parametric oklch ramp system: formula, named palettes, semantic slots, generator, consumer setup
|
|
31
31
|
├── theming-typography-tokens.md — --step-N fluid font-size scale, 62.5% root reset gotcha (1rem=10px, not 16px), page-heading-*/page-body-* utility classes
|
|
32
32
|
├── theming-override-default.md — replace the entire default theme with a custom palette (set --theme-hue/--theme-chroma)
|
|
33
|
-
├── theming-partial-override.md — override a specific token category (palette, buttons, inputs) without a full theme replacement
|
|
33
|
+
├── theming-partial-override.md — override a specific colour token category (palette, buttons, inputs) without a full theme replacement
|
|
34
|
+
├── theming-form-geometry-tokens.md — non-colour form/button/input tokens (padding, border-radius, gaps): full inventory, why partial override doesn't need duplicating the source files
|
|
34
35
|
├── colour-scheme-disable.md — disable light/dark scheme support in a consumer app
|
|
35
36
|
├── component-dynamic-slots.md — named dynamic slots ($slots iteration) vs indexed dynamic slots (itemCount pattern)
|
|
36
37
|
├── component-local-style-override.md — styleClassPassthrough + scoped style block for per-usage visual customisation
|
|
@@ -47,7 +48,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
47
48
|
├── robots-env-aware.md — @nuxtjs/robots: allow crawling on prod domain only, block on preview/staging via env var
|
|
48
49
|
├── new-app-scaffold.md — scaffold a new Nuxt consumer app extending this layer (package.json, nuxt.config, app structure, CLAUDE.md)
|
|
49
50
|
├── qa-panel.md — collapsible panel for toggling component props live on a page (always on for /pages/ui/ demo pages; gate with isDev in consuming apps)
|
|
50
|
-
├── release-notes.md
|
|
51
|
+
├── create-release-notes.md — produce release notes as a fenced markdown block from git log (named to avoid colliding with the built-in /release-notes command)
|
|
51
52
|
├── pull-request-description.md — produce a PR description as a fenced markdown block from git diff vs main
|
|
52
53
|
├── using-component-skills.md — discover and use component skills in consumer apps: where skills land, browsing patterns, workflow for deciding build vs. compose
|
|
53
54
|
├── composable-canonical-url.md — useCanonicalUrl: set <link rel="canonical"> from runtimeConfig.public.canonicalHost; layout setup, node types
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Form Geometry Token Override
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`setup/04.elements/forms/*.css` defines every non-colour form/button/input token — padding,
|
|
6
|
+
border-radius, gaps, font-size, focus-ring width, transition duration. This is the geometry
|
|
7
|
+
counterpart to the colour tokens covered in `theming-partial-override.md` and the type-scale
|
|
8
|
+
tokens in `theming-typography-tokens.md`.
|
|
9
|
+
|
|
10
|
+
**You do not need to duplicate these files wholesale to override them.** Every value here is a
|
|
11
|
+
plain custom property declared on `:where(html)` inside the `form-tokens` cascade layer (see
|
|
12
|
+
`@import "./04.elements/" layer(form-tokens);` in `setup/index.css`). A consumer app's own CSS —
|
|
13
|
+
as long as it isn't itself wrapped in a named `@layer` — always wins over layered CSS regardless
|
|
14
|
+
of source order, because unlayered rules are cascade-ordered after every named layer per the CSS
|
|
15
|
+
Cascade Layers spec. Concretely: it does not matter whether the consumer's override file is
|
|
16
|
+
imported before or after this layer's `main.css` in `nuxt.config.ts`'s `css` array — unlayered
|
|
17
|
+
always wins either way. `theming-override-default.md`/`theming-partial-override.md` already rely
|
|
18
|
+
on this same mechanism for colour tokens; it applies identically here.
|
|
19
|
+
|
|
20
|
+
So: to change one geometry value, redeclare just that one custom property in your app's own CSS
|
|
21
|
+
(e.g. `app/assets/styles/setup/03.theming/_default.css`, or a new file imported the same way).
|
|
22
|
+
There is no dependency between these tokens — overriding `--button-padding-inline` alone does not
|
|
23
|
+
require also redeclaring `--button-border-radius` or anything else in the same source file.
|
|
24
|
+
|
|
25
|
+
## The token inventory
|
|
26
|
+
|
|
27
|
+
From `00.element-defaults.css` through `09.animation-motion.css`:
|
|
28
|
+
|
|
29
|
+
| File | Tokens |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `01.field-layout-container-level.css` | `--field-margin-block`, `--field-gap-block`, `--field-gap-inline`, `--field-label-gap`, `--field-description-gap`, `--field-error-gap` |
|
|
32
|
+
| `02.typography.css` | `--field-label-font-size/-weight/-line-height`, `--input-font-size`, `--input-line-height`, `--theme-input-placeholder-font-size`, `--button-font-size/-line-height/-weight/-text-transform`, `--field-description-font-size/-line-height`, `--field-error-font-size/-weight` |
|
|
33
|
+
| `03.generic-input-geometry.css` | `--input-padding-block/-inline`, `--input-padding-inline-with-icon`, `--form-element-border-width`, `--form-element-border-bottom-width-underlined`, `--form-input-border-radius[-underlined]`, `--form-element-outline-width[-focus]`, `--form-element-outline-offset-focus`, `--input-focus-ring-width/-offset`, `--input-underline-width`, `--input-min-height`, `--input-element-line-height`, plus per-element-type geometry (textarea/select/range/checkbox/toggle — see file for the full set) |
|
|
34
|
+
| `04.slot-icon-system.css` | `--input-slot-width/-gap`, `--input-icon-size`, `--input-icon-slot-gap`, `--input-icon-color[-focus/-error]` |
|
|
35
|
+
| `05.checkbox-radio-geometry.css` | `--control-size`, `--control-border-radius`, `--radio-border-radius`, `--control-border-width`, `--control-indicator-scale`, `--control-label-gap` |
|
|
36
|
+
| `06.button-geometry.css` | `--button-padding-block/-inline`, `--button-border-radius[-icon-only]`, `--button-border-width`, `--button-outline-width`, `--button-focus-ring-width/-offset`, `--button-icon-gap` |
|
|
37
|
+
| `07.validation-error-block-layout.css` | `--error-icon-size/-gap`, `--error-border-radius`, `--error-padding-block/-inline` |
|
|
38
|
+
| `09.animation-motion.css` | `--control-transition-duration/-ease`, `--theme-form-transition-duration` |
|
|
39
|
+
|
|
40
|
+
`00.element-defaults.css` is the one exception — it's not tokens, it's a real selector-based rule
|
|
41
|
+
(browser autofill background/text-colour reset via `input:autofill` etc.). It reads
|
|
42
|
+
`var(--theme-surface)` and the component-private `--_input-text-core-color`, so changing
|
|
43
|
+
`--theme-surface` (per `theming-partial-override.md`) changes autofill colours too — no separate
|
|
44
|
+
override needed there. If you need to change that rule's *behaviour* itself (not just its colour
|
|
45
|
+
inputs), you'd need a competing unlayered selector of your own targeting the same pseudo-classes,
|
|
46
|
+
not a token.
|
|
47
|
+
|
|
48
|
+
## Example
|
|
49
|
+
|
|
50
|
+
```css
|
|
51
|
+
/* app/assets/styles/setup/03.theming/_default.css, or any unlayered file registered in nuxt.config.ts */
|
|
52
|
+
:where(html) {
|
|
53
|
+
--button-padding-block: 1.2rem;
|
|
54
|
+
--button-padding-inline: 2.4rem;
|
|
55
|
+
--button-border-radius: 0.8rem;
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Notes
|
|
60
|
+
|
|
61
|
+
- Uses the layer's `62.5%` root font-size reset — see `theming-typography-tokens.md` before
|
|
62
|
+
hand-writing any raw `rem` value here.
|
|
63
|
+
- `--theme-surface`, `--theme-input-surface`, etc. referenced by these geometry tokens are colour
|
|
64
|
+
tokens covered in `theming-partial-override.md`, not this skill — geometry and colour are
|
|
65
|
+
overridden the same mechanical way, just different token names.
|
|
66
|
+
- Found and fixed while writing this skill (2026-08-03): `00.element-defaults.css` referenced a
|
|
67
|
+
`--theme-button-surface` token that was never declared anywhere in this codebase — a
|
|
68
|
+
since-corrected typo for `--theme-surface`, present since the rule was first introduced. Silently
|
|
69
|
+
broke autofill background/box-shadow (an undefined `var()` with no fallback resolves to the
|
|
70
|
+
property's initial value). A reminder that a dangling `var()` reference produces no error/warning
|
|
71
|
+
of any kind — it just quietly does nothing.
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
Use this skill when you need to tweak a specific colour role (page background, text, buttons,
|
|
6
6
|
inputs) without replacing the entire default theme. For a full palette swap see
|
|
7
|
-
`theming-override-default.md`. For the full architecture see `theming-colour-ramps.md`.
|
|
7
|
+
`theming-override-default.md`. For the full architecture see `theming-colour-ramps.md`. For
|
|
8
|
+
non-colour form geometry (padding, border-radius, gaps, etc.) see `theming-form-geometry-tokens.md`
|
|
9
|
+
— same override mechanism, different token set.
|
|
8
10
|
|
|
9
11
|
## How it works
|
|
10
12
|
|
|
@@ -14,20 +16,22 @@ after the layer styles.
|
|
|
14
16
|
|
|
15
17
|
## Semantic slots — the tokens to override
|
|
16
18
|
|
|
17
|
-
All themed components share
|
|
18
|
-
prompts, and toasts simultaneously:
|
|
19
|
-
|
|
20
|
-
| Token
|
|
21
|
-
|
|
22
|
-
| `--theme-surface`
|
|
23
|
-
| `--theme-surface-hover`
|
|
24
|
-
| `--theme-
|
|
25
|
-
| `--theme-
|
|
26
|
-
| `--theme-
|
|
27
|
-
| `--theme-border
|
|
28
|
-
| `--theme-
|
|
29
|
-
| `--theme-
|
|
30
|
-
| `--theme-
|
|
19
|
+
All themed components share an 11-slot vocabulary (see `_theme-slots.css`). Overriding these
|
|
20
|
+
affects buttons, inputs, prompts, and toasts simultaneously:
|
|
21
|
+
|
|
22
|
+
| Token | Default (light → dark) | Role |
|
|
23
|
+
| --------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
24
|
+
| `--theme-surface` | `--colour-theme-7` / `9` | Filled button/chip surface |
|
|
25
|
+
| `--theme-surface-hover` | `--colour-theme-9` / `7` | Hover state of filled surface |
|
|
26
|
+
| `--theme-surface-inverted` | `--colour-theme-9` / `2` | Inverted surface (`InputButtonCore`'s `.secondary` background) |
|
|
27
|
+
| `--theme-accent` | `--colour-theme-5` / `4` | Decorative accent strip (prompt/toast left edge) |
|
|
28
|
+
| `--theme-surface-subtle` | `--colour-theme-1` / `9` | Subtle body bg for prompt/toast, outline element hover |
|
|
29
|
+
| `--theme-border` | `--colour-theme-6` / `5` | Input/card border |
|
|
30
|
+
| `--theme-border-focus` | `--colour-theme-4` / `3` | Focused border |
|
|
31
|
+
| `--theme-ring` | `--colour-theme-1` / `9` | Focus ring (outline) |
|
|
32
|
+
| `--theme-on-surface` | `--colour-theme-0` | Text/icon on filled/inverted surface (fixed light value in both modes — pair this with any dark-in-light-mode surface, not `--theme-text`) |
|
|
33
|
+
| `--theme-text` | `--colour-theme-9` / `2` | Text on page, outline element text (assumes a *light* surface — don't pair with `--theme-surface-hover`/`-inverted`, see pitfall #13 in `Claude.md`) |
|
|
34
|
+
| `--theme-text-inverted` | `--colour-theme-0` / `7` | Text on `--theme-surface-inverted` (fixed to a light value in light mode as of 2026-08-03 — was `--colour-theme-9`, identical to the surface it sits on) |
|
|
31
35
|
|
|
32
36
|
Input-specific tokens:
|
|
33
37
|
|
|
@@ -102,6 +102,25 @@
|
|
|
102
102
|
"</PricingCard>"
|
|
103
103
|
]
|
|
104
104
|
},
|
|
105
|
+
"SRCDEV PricingCard Ribbon": {
|
|
106
|
+
"description": "PricingCard with a diagonal discount ribbon",
|
|
107
|
+
"scope": "vue,html",
|
|
108
|
+
"body": [
|
|
109
|
+
"<PricingCard",
|
|
110
|
+
" planName=\"$1Annual\"",
|
|
111
|
+
" :price=\"$249\"",
|
|
112
|
+
" billingPeriod=\"$3per year\"",
|
|
113
|
+
" description=\"$4Early adopter pricing on the annual plan.\"",
|
|
114
|
+
" ribbonText=\"$5EARLYBIRD50 · 50% off\"",
|
|
115
|
+
" :features=\"[",
|
|
116
|
+
" '$6Feature 1',",
|
|
117
|
+
" '$7Feature 2',",
|
|
118
|
+
" '$8Feature 3',",
|
|
119
|
+
" ]\"",
|
|
120
|
+
" @select=\"$9handleSelect\"",
|
|
121
|
+
"/>"
|
|
122
|
+
]
|
|
123
|
+
},
|
|
105
124
|
"SRCDEV PricingCard CSS Override": {
|
|
106
125
|
"description": "CSS override scaffold for PricingCard tokens",
|
|
107
126
|
"scope": "css",
|
|
@@ -116,11 +135,13 @@
|
|
|
116
135
|
" --pricing-card-amount-color: $7;",
|
|
117
136
|
" --pricing-card-cta-bg: $8;",
|
|
118
137
|
" --pricing-card-cta-bg-hover: $9;",
|
|
138
|
+
" --pricing-card-ribbon-bg: $10;",
|
|
139
|
+
" --pricing-card-ribbon-text: $11;",
|
|
119
140
|
"",
|
|
120
141
|
" &.is-highlighted {",
|
|
121
|
-
" --pricing-card-highlight-border: $
|
|
122
|
-
" --pricing-card-highlight-shadow: $
|
|
123
|
-
" --pricing-card-highlight-scale: $
|
|
142
|
+
" --pricing-card-highlight-border: $12;",
|
|
143
|
+
" --pricing-card-highlight-shadow: $13;",
|
|
144
|
+
" --pricing-card-highlight-scale: $14;",
|
|
124
145
|
" }",
|
|
125
146
|
" }",
|
|
126
147
|
"}"
|
|
@@ -12,5 +12,10 @@
|
|
|
12
12
|
--theme-ring: light-dark(var(--colour-theme-1), var(--colour-theme-9));
|
|
13
13
|
--theme-on-surface: var(--colour-theme-0);
|
|
14
14
|
--theme-text: light-dark(var(--colour-theme-9), var(--colour-theme-2));
|
|
15
|
-
|
|
15
|
+
/* Light-mode branch was --colour-theme-9 — identical to --theme-surface-inverted's own
|
|
16
|
+
light-mode branch (line 6), its only pairing (InputButtonCore's .secondary, background +
|
|
17
|
+
color), so secondary-button text was invisible (dark-on-dark) in light mode. Should be a
|
|
18
|
+
light step to contrast against that dark inverted surface, matching how --theme-on-surface
|
|
19
|
+
(line 13) contrasts against --theme-surface the same way in both modes. */
|
|
20
|
+
--theme-text-inverted: light-dark(var(--colour-theme-0), var(--colour-theme-7));
|
|
16
21
|
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
|
+
/* Was --theme-button-surface, a token never declared anywhere in this codebase (confirmed via
|
|
2
|
+
git blame — introduced in 065f254 alongside this rule, before _theme-slots.css even existed,
|
|
3
|
+
and never matched afterwards) — a plain typo/omission, not an intentional distinct token.
|
|
4
|
+
Autofill background/box-shadow silently had no effect since an undefined var() with no
|
|
5
|
+
fallback resolves to the property's initial value. Fixed to the real token, 2026-08-03. */
|
|
1
6
|
:where(html) {
|
|
2
7
|
input:autofill,
|
|
3
8
|
input:-internal-autofill-selected,
|
|
4
9
|
input:-webkit-autofill-strong-password,
|
|
5
10
|
input:-webkit-autofill-strong-password-viewable,
|
|
6
11
|
input:-webkit-autofill-and-obscured {
|
|
7
|
-
background-color: var(--theme-
|
|
12
|
+
background-color: var(--theme-surface) !important;
|
|
8
13
|
background-image: none !important;
|
|
9
14
|
color: var(--_input-text-core-color) !important;
|
|
10
|
-
-webkit-box-shadow: 0 0 0rem 1000px var(--theme-
|
|
15
|
+
-webkit-box-shadow: 0 0 0rem 1000px var(--theme-surface) inset;
|
|
11
16
|
-webkit-text-fill-color: var(--_input-text-core-color);
|
|
12
17
|
transition: background-color 5000s ease-in-out 0s;
|
|
13
18
|
}
|
|
@@ -4,7 +4,10 @@ exports[`AccordianCore > renders correct HTML structure with all props set 1`] =
|
|
|
4
4
|
"<div class="display-accordian extra-class">
|
|
5
5
|
<div class="expanding-panel accordian-item" icon-size="medium">
|
|
6
6
|
<details class="expanding-panel-details" name="snapshot-accordian">
|
|
7
|
-
<summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false"
|
|
7
|
+
<summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false">
|
|
8
|
+
<div class="label-wrapper"></div>
|
|
9
|
+
<div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
|
|
10
|
+
</summary>
|
|
8
11
|
</details>
|
|
9
12
|
<div id="id-snapshot-accordian-content" class="expanding-panel-content" aria-labelledby="id-snapshot-accordian-trigger" role="region">
|
|
10
13
|
<div class="inner"></div>
|
|
@@ -12,7 +15,10 @@ exports[`AccordianCore > renders correct HTML structure with all props set 1`] =
|
|
|
12
15
|
</div>
|
|
13
16
|
<div class="expanding-panel accordian-item" icon-size="medium">
|
|
14
17
|
<details class="expanding-panel-details" name="snapshot-accordian">
|
|
15
|
-
<summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false"
|
|
18
|
+
<summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false">
|
|
19
|
+
<div class="label-wrapper"></div>
|
|
20
|
+
<div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
|
|
21
|
+
</summary>
|
|
16
22
|
</details>
|
|
17
23
|
<div id="id-snapshot-accordian-content" class="expanding-panel-content" aria-labelledby="id-snapshot-accordian-trigger" role="region">
|
|
18
24
|
<div class="inner"></div>
|
|
@@ -20,7 +26,10 @@ exports[`AccordianCore > renders correct HTML structure with all props set 1`] =
|
|
|
20
26
|
</div>
|
|
21
27
|
<div class="expanding-panel accordian-item" icon-size="medium">
|
|
22
28
|
<details class="expanding-panel-details" name="snapshot-accordian">
|
|
23
|
-
<summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false"
|
|
29
|
+
<summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false">
|
|
30
|
+
<div class="label-wrapper"></div>
|
|
31
|
+
<div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
|
|
32
|
+
</summary>
|
|
24
33
|
</details>
|
|
25
34
|
<div id="id-snapshot-accordian-content" class="expanding-panel-content" aria-labelledby="id-snapshot-accordian-trigger" role="region">
|
|
26
35
|
<div class="inner"></div>
|
|
@@ -33,7 +42,10 @@ exports[`AccordianCore > renders correct HTML structure with default props 1`] =
|
|
|
33
42
|
"<div class="display-accordian">
|
|
34
43
|
<div class="expanding-panel accordian-item" icon-size="medium">
|
|
35
44
|
<details class="expanding-panel-details" name="v-0-0-0">
|
|
36
|
-
<summary id="id-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-0-content" aria-expanded="false"
|
|
45
|
+
<summary id="id-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-0-content" aria-expanded="false">
|
|
46
|
+
<div class="label-wrapper"></div>
|
|
47
|
+
<div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
|
|
48
|
+
</summary>
|
|
37
49
|
</details>
|
|
38
50
|
<div id="id-v-0-0-0-content" class="expanding-panel-content" aria-labelledby="id-v-0-0-0-trigger" role="region">
|
|
39
51
|
<div class="inner"></div>
|
|
@@ -41,7 +53,10 @@ exports[`AccordianCore > renders correct HTML structure with default props 1`] =
|
|
|
41
53
|
</div>
|
|
42
54
|
<div class="expanding-panel accordian-item" icon-size="medium">
|
|
43
55
|
<details class="expanding-panel-details" name="v-0-0-1">
|
|
44
|
-
<summary id="id-v-0-0-1-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-1-content" aria-expanded="false"
|
|
56
|
+
<summary id="id-v-0-0-1-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-1-content" aria-expanded="false">
|
|
57
|
+
<div class="label-wrapper"></div>
|
|
58
|
+
<div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
|
|
59
|
+
</summary>
|
|
45
60
|
</details>
|
|
46
61
|
<div id="id-v-0-0-1-content" class="expanding-panel-content" aria-labelledby="id-v-0-0-1-trigger" role="region">
|
|
47
62
|
<div class="inner"></div>
|
|
@@ -54,7 +69,10 @@ exports[`AccordianCore > renders correct HTML structure with populated slots 1`]
|
|
|
54
69
|
"<div class="display-accordian">
|
|
55
70
|
<div class="expanding-panel accordian-item" icon-size="medium">
|
|
56
71
|
<details class="expanding-panel-details" name="v-0-0-0">
|
|
57
|
-
<summary id="id-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-0-content" aria-expanded="false"
|
|
72
|
+
<summary id="id-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-0-content" aria-expanded="false">
|
|
73
|
+
<div class="label-wrapper"><span>Summary 0</span></div>
|
|
74
|
+
<div class="icon-wrapper"><span>Icon 0</span></div>
|
|
75
|
+
</summary>
|
|
58
76
|
</details>
|
|
59
77
|
<div id="id-v-0-0-0-content" class="expanding-panel-content" aria-labelledby="id-v-0-0-0-trigger" role="region">
|
|
60
78
|
<div class="inner"><span>Content 0</span></div>
|
|
@@ -62,7 +80,10 @@ exports[`AccordianCore > renders correct HTML structure with populated slots 1`]
|
|
|
62
80
|
</div>
|
|
63
81
|
<div class="expanding-panel accordian-item" icon-size="medium">
|
|
64
82
|
<details class="expanding-panel-details" name="v-0-0-1">
|
|
65
|
-
<summary id="id-v-0-0-1-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-1-content" aria-expanded="false"
|
|
83
|
+
<summary id="id-v-0-0-1-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-1-content" aria-expanded="false">
|
|
84
|
+
<div class="label-wrapper"><span>Summary 1</span></div>
|
|
85
|
+
<div class="icon-wrapper"><span>Icon 1</span></div>
|
|
86
|
+
</summary>
|
|
66
87
|
</details>
|
|
67
88
|
<div id="id-v-0-0-1-content" class="expanding-panel-content" aria-labelledby="id-v-0-0-1-trigger" role="region">
|
|
68
89
|
<div class="inner"><span>Content 1</span></div>
|
|
@@ -21,6 +21,9 @@ override it — there is no locale-aware formatting, just a literal prefix chara
|
|
|
21
21
|
| `--pricing-card-highlight-scale` | `1.05` | Scale transform when highlighted |
|
|
22
22
|
| `--pricing-card-badge-bg` | `var(--teal-06)` | "Most Popular" badge background |
|
|
23
23
|
| `--pricing-card-badge-text` | `var(--teal-00)` | Badge text colour |
|
|
24
|
+
| `--pricing-card-ribbon-bg` | `var(--red-06)` | Diagonal corner ribbon background (rendered when `ribbon-text` is set) |
|
|
25
|
+
| `--pricing-card-ribbon-text` | `var(--red-00, white)` | Ribbon text colour |
|
|
26
|
+
| `--pricing-card-ribbon-size` | `20rem` | Width/height of the ribbon's clipping box — controls how far it extends from the top-right corner |
|
|
24
27
|
| `--pricing-card-name-font-size` | `1.8rem` | Plan name heading size |
|
|
25
28
|
| `--pricing-card-name-color` | `#1a1a1a` | Plan name text colour |
|
|
26
29
|
| `--pricing-card-amount-font-size` | `3.2rem` | Price amount size |
|
|
@@ -128,6 +131,32 @@ When displaying multiple cards side-by-side (common in pricing pages), wrap in a
|
|
|
128
131
|
|
|
129
132
|
---
|
|
130
133
|
|
|
134
|
+
## Discount ribbon
|
|
135
|
+
|
|
136
|
+
Pass `ribbon-text` to render a diagonal corner ribbon (top-right, 45°) for marketing callouts —
|
|
137
|
+
leave it unset for no ribbon. It renders independently of `isHighlighted`, so both can be combined.
|
|
138
|
+
|
|
139
|
+
```vue
|
|
140
|
+
<PricingCard
|
|
141
|
+
planName="Annual"
|
|
142
|
+
:price="49"
|
|
143
|
+
billing-period="per year"
|
|
144
|
+
ribbon-text="EARLYBIRD50 · 50% off"
|
|
145
|
+
@select="handleSelect"
|
|
146
|
+
/>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```css
|
|
150
|
+
.pricing-page {
|
|
151
|
+
.pricing-card {
|
|
152
|
+
--pricing-card-ribbon-bg: var(--brand-accent);
|
|
153
|
+
--pricing-card-ribbon-text: white;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
131
160
|
## Feature checkmark colour
|
|
132
161
|
|
|
133
162
|
The feature list checkmarks use `--teal-06` by default. Override globally or per-instance:
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
<component :is="tag" class="pricing-card" :class="[elementClasses, { 'is-highlighted': isHighlighted }]">
|
|
3
3
|
<div v-if="isHighlighted" class="pricing-card__badge">Most Popular</div>
|
|
4
4
|
|
|
5
|
+
<div v-if="ribbonText" class="pricing-card__ribbon-clip">
|
|
6
|
+
<span class="pricing-card__ribbon">{{ ribbonText }}</span>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
5
9
|
<h3 class="pricing-card__name">{{ planName }}</h3>
|
|
6
10
|
|
|
7
11
|
<div class="pricing-card__price">
|
|
@@ -20,18 +24,8 @@
|
|
|
20
24
|
</ul>
|
|
21
25
|
|
|
22
26
|
<div class="pricing-card__cta">
|
|
23
|
-
<slot
|
|
24
|
-
|
|
25
|
-
:cta-text="ctaText"
|
|
26
|
-
:is-disabled="ctaDisabled"
|
|
27
|
-
:plan-name="planName"
|
|
28
|
-
:on-select="handleSelect"
|
|
29
|
-
>
|
|
30
|
-
<InputButtonCore
|
|
31
|
-
:button-text="ctaText"
|
|
32
|
-
:readonly="ctaDisabled"
|
|
33
|
-
@click="handleSelect"
|
|
34
|
-
/>
|
|
27
|
+
<slot name="cta" :cta-text="ctaText" :is-disabled="ctaDisabled" :plan-name="planName" :on-select="handleSelect">
|
|
28
|
+
<InputButtonCore :button-text="ctaText" :readonly="ctaDisabled" @click="handleSelect" />
|
|
35
29
|
</slot>
|
|
36
30
|
</div>
|
|
37
31
|
</component>
|
|
@@ -49,6 +43,7 @@ interface Props {
|
|
|
49
43
|
isHighlighted?: boolean;
|
|
50
44
|
ctaText?: string;
|
|
51
45
|
ctaDisabled?: boolean;
|
|
46
|
+
ribbonText?: string;
|
|
52
47
|
styleClassPassthrough?: string | string[];
|
|
53
48
|
}
|
|
54
49
|
|
|
@@ -61,6 +56,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
61
56
|
isHighlighted: false,
|
|
62
57
|
ctaText: "Get started",
|
|
63
58
|
ctaDisabled: false,
|
|
59
|
+
ribbonText: undefined,
|
|
64
60
|
styleClassPassthrough: () => [],
|
|
65
61
|
});
|
|
66
62
|
|
|
@@ -78,7 +74,7 @@ watch(
|
|
|
78
74
|
() => props.styleClassPassthrough,
|
|
79
75
|
() => {
|
|
80
76
|
resetElementClasses(props.styleClassPassthrough);
|
|
81
|
-
}
|
|
77
|
+
}
|
|
82
78
|
);
|
|
83
79
|
</script>
|
|
84
80
|
|
|
@@ -93,12 +89,16 @@ watch(
|
|
|
93
89
|
--_card-gap: var(--pricing-card-gap, 1.2rem);
|
|
94
90
|
|
|
95
91
|
--_highlight-border: var(--pricing-card-highlight-border, 2px solid var(--teal-06));
|
|
96
|
-
--_highlight-shadow: var(--pricing-card-highlight-shadow, 0 8px 24px oklch(from var(--teal-06) l c h / 0.
|
|
92
|
+
--_highlight-shadow: var(--pricing-card-highlight-shadow, 0 8px 24px oklch(from var(--teal-06) l c h / 0.2));
|
|
97
93
|
--_highlight-scale: var(--pricing-card-highlight-scale, 1.05);
|
|
98
94
|
|
|
99
95
|
--_badge-bg: var(--pricing-card-badge-bg, var(--teal-06));
|
|
100
96
|
--_badge-text: var(--pricing-card-badge-text, var(--teal-00));
|
|
101
97
|
|
|
98
|
+
--_ribbon-bg: var(--pricing-card-ribbon-bg, var(--red-06));
|
|
99
|
+
--_ribbon-text: var(--pricing-card-ribbon-text, var(--red-00, white));
|
|
100
|
+
--_ribbon-size: var(--pricing-card-ribbon-size, 20rem);
|
|
101
|
+
|
|
102
102
|
--_name-font-size: var(--pricing-card-name-font-size, 1.8rem);
|
|
103
103
|
--_name-color: var(--pricing-card-name-color, #1a1a1a);
|
|
104
104
|
|
|
@@ -153,6 +153,37 @@ watch(
|
|
|
153
153
|
letter-spacing: 0.05em;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
.pricing-card__ribbon-clip {
|
|
157
|
+
position: absolute;
|
|
158
|
+
top: 0;
|
|
159
|
+
right: 0;
|
|
160
|
+
width: var(--_ribbon-size);
|
|
161
|
+
height: var(--_ribbon-size);
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
pointer-events: none;
|
|
164
|
+
/* Sits above card content but stays clear of the top-centred "Most Popular" badge. */
|
|
165
|
+
z-index: 1;
|
|
166
|
+
|
|
167
|
+
.pricing-card__ribbon {
|
|
168
|
+
position: absolute;
|
|
169
|
+
top: 1.6rem;
|
|
170
|
+
right: -1.4rem;
|
|
171
|
+
width: calc(var(--_ribbon-size) + 2rem);
|
|
172
|
+
transform: rotate(23deg);
|
|
173
|
+
transform-origin: center;
|
|
174
|
+
|
|
175
|
+
display: block;
|
|
176
|
+
padding: 0.6rem 1.4rem 0.6rem 0;
|
|
177
|
+
background-color: var(--_ribbon-bg);
|
|
178
|
+
color: var(--_ribbon-text);
|
|
179
|
+
text-align: right;
|
|
180
|
+
font-size: 1.2rem;
|
|
181
|
+
font-weight: 700;
|
|
182
|
+
text-transform: uppercase;
|
|
183
|
+
letter-spacing: 0.02em;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
156
187
|
.pricing-card__name {
|
|
157
188
|
margin: 0;
|
|
158
189
|
font-size: var(--_name-font-size);
|
|
@@ -13,6 +13,7 @@ const meta: Meta<typeof PricingCard> = {
|
|
|
13
13
|
isHighlighted: { control: "boolean" },
|
|
14
14
|
ctaText: { control: "text" },
|
|
15
15
|
ctaDisabled: { control: "boolean" },
|
|
16
|
+
ribbonText: { control: "text" },
|
|
16
17
|
},
|
|
17
18
|
args: {
|
|
18
19
|
planName: "Single Site",
|
|
@@ -23,6 +24,7 @@ const meta: Meta<typeof PricingCard> = {
|
|
|
23
24
|
isHighlighted: false,
|
|
24
25
|
ctaText: "Get started",
|
|
25
26
|
ctaDisabled: false,
|
|
27
|
+
ribbonText: undefined,
|
|
26
28
|
},
|
|
27
29
|
parameters: {
|
|
28
30
|
docs: {
|
|
@@ -30,6 +32,8 @@ const meta: Meta<typeof PricingCard> = {
|
|
|
30
32
|
component:
|
|
31
33
|
"A SaaS pricing plan card displaying plan name, price, features, and a CTA button. " +
|
|
32
34
|
"Supports a 'Most Popular' highlight state with scaled/emphasized styling. " +
|
|
35
|
+
"Supports an optional `ribbonText` prop that renders a diagonal corner ribbon (top-right, " +
|
|
36
|
+
"45°) for marketing callouts like a discount — leave it unset for no ribbon. " +
|
|
33
37
|
"Emits a `select` event when the CTA button is clicked. " +
|
|
34
38
|
"Use multiple cards in a grid layout to build a pricing comparison table.",
|
|
35
39
|
},
|
|
@@ -116,6 +120,23 @@ export const Disabled: Story = {
|
|
|
116
120
|
},
|
|
117
121
|
};
|
|
118
122
|
|
|
123
|
+
export const WithDiscountRibbon: Story = {
|
|
124
|
+
args: {
|
|
125
|
+
planName: "Annual",
|
|
126
|
+
price: 49,
|
|
127
|
+
billingPeriod: "per year",
|
|
128
|
+
description: "Early adopter pricing on the annual plan.",
|
|
129
|
+
features: ["Embed on one domain", "Unlimited consultations", "Email support", "Priced annually"],
|
|
130
|
+
ribbonText: "EARLYBIRD50 · 50% off",
|
|
131
|
+
},
|
|
132
|
+
render: (args) => {
|
|
133
|
+
const handleSelect = (planName: string) => {
|
|
134
|
+
console.log(`Selected plan: ${planName}`);
|
|
135
|
+
};
|
|
136
|
+
return storyWrapper(args, handleSelect);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
119
140
|
export const ThreeCardComparison: Story = {
|
|
120
141
|
render: () => ({
|
|
121
142
|
components: { PricingCard },
|
|
@@ -150,4 +150,29 @@ describe("PricingCard", () => {
|
|
|
150
150
|
|
|
151
151
|
expect(wrapper.element.tagName).toBe("SECTION");
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
it("does not render a ribbon when ribbonText is unset", () => {
|
|
155
|
+
const wrapper = mount(PricingCard, {
|
|
156
|
+
props: {
|
|
157
|
+
planName: "Test Plan",
|
|
158
|
+
price: 100,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(wrapper.find(".pricing-card__ribbon-clip").exists()).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("renders ribbon text when ribbonText is provided", () => {
|
|
166
|
+
const wrapper = mount(PricingCard, {
|
|
167
|
+
props: {
|
|
168
|
+
planName: "Test Plan",
|
|
169
|
+
price: 100,
|
|
170
|
+
ribbonText: "EARLYBIRD50 · 50% off",
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const ribbon = wrapper.find(".pricing-card__ribbon-clip");
|
|
175
|
+
expect(ribbon.exists()).toBe(true);
|
|
176
|
+
expect(ribbon.text()).toContain("EARLYBIRD50 · 50% off");
|
|
177
|
+
});
|
|
153
178
|
});
|
|
@@ -63,7 +63,10 @@ exports[`ServicesSection > renders correct HTML structure 1`] = `
|
|
|
63
63
|
<div class="display-accordian services-section__faq" id="faq">
|
|
64
64
|
<div class="expanding-panel accordian-item" icon-size="medium">
|
|
65
65
|
<details class="expanding-panel-details" name="faq-v-0-0-0">
|
|
66
|
-
<summary id="id-faq-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-faq-v-0-0-0-content" aria-expanded="false"
|
|
66
|
+
<summary id="id-faq-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-faq-v-0-0-0-content" aria-expanded="false">
|
|
67
|
+
<div class="label-wrapper">FAQ question?</div>
|
|
68
|
+
<div class="icon-wrapper"><span class="iconify i-mdi:chevron-down" aria-hidden="true"></span></div>
|
|
69
|
+
</summary>
|
|
67
70
|
</details>
|
|
68
71
|
<div id="id-faq-v-0-0-0-content" class="expanding-panel-content" aria-labelledby="id-faq-v-0-0-0-trigger" role="region">
|
|
69
72
|
<div class="inner">
|
|
@@ -111,6 +111,7 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
|
111
111
|
padding-inline: var(--button-padding-inline);
|
|
112
112
|
padding-block: var(--button-padding-block);
|
|
113
113
|
touch-action: manipulation;
|
|
114
|
+
user-select: none;
|
|
114
115
|
overflow: hidden;
|
|
115
116
|
transition: all var(--control-transition-duration) var(--control-transition-ease);
|
|
116
117
|
|
|
@@ -129,7 +130,13 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
|
129
130
|
&:hover,
|
|
130
131
|
&:focus-visible {
|
|
131
132
|
background-color: var(--theme-surface-hover);
|
|
132
|
-
|
|
133
|
+
/* --theme-on-surface, not --theme-text: --theme-surface-hover's light-mode branch is a
|
|
134
|
+
dark step (by design — this button stays a bold colour even in light mode), and
|
|
135
|
+
--theme-text's light-mode branch is dark too (it's meant for text on a light surface
|
|
136
|
+
elsewhere, e.g. AlertContentInner). Pairing them made hover text invisible in light
|
|
137
|
+
mode. --theme-on-surface is a fixed light value regardless of scheme, matching how
|
|
138
|
+
AlertContentInner's own dismiss-button hover and .secondary:hover below already do it. */
|
|
139
|
+
color: var(--theme-on-surface);
|
|
133
140
|
border-color: var(--theme-border-focus);
|
|
134
141
|
outline-color: var(--theme-ring);
|
|
135
142
|
}
|
|
@@ -163,6 +170,9 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
|
163
170
|
&:hover,
|
|
164
171
|
&:focus-visible {
|
|
165
172
|
background-color: var(--theme-surface-hover);
|
|
173
|
+
/* Same fix as the base :hover above — background goes dark in light mode, so text
|
|
174
|
+
needs to switch off --theme-text (dark in light mode) to stay visible. */
|
|
175
|
+
color: var(--theme-on-surface);
|
|
166
176
|
border-color: var(--theme-border-focus);
|
|
167
177
|
outline-color: var(--theme-border-focus);
|
|
168
178
|
}
|
package/package.json
CHANGED
|
File without changes
|