srcdev-nuxt-components 9.0.3 → 9.0.5
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.local.json +12 -0
- package/.claude/skills/components/eyebrow-text.md +71 -0
- package/.claude/skills/components/hero-text.md +97 -0
- package/.claude/skills/components/link-text.md +101 -0
- package/.claude/skills/index.md +54 -0
- package/.claude/skills/storybook-add-font.md +101 -0
- package/.claude/skills/storybook-add-story.md +164 -0
- package/.claude/skills/testing-add-playwright.md +167 -0
- package/.claude/skills/testing-add-unit-test.md +185 -0
- package/.claude/skills/theming-override-default.md +238 -0
- package/README.md +20 -0
- package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +66 -0
- package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +140 -0
- package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +168 -0
- package/package.json +2 -3
- /package/{types → app/types}/components/css-anchor-polyfill.d.ts +0 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Adding a Playwright Visual Test
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Playwright tests run pixel-level screenshot comparisons against a running Storybook instance.
|
|
6
|
+
Each test navigates to a story URL, locates the component element, and asserts it matches
|
|
7
|
+
a stored PNG baseline.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
Storybook must be running before Playwright tests can execute. Use `npm run storybook:serve`
|
|
12
|
+
(build + serve static) for stable baselines, or `npm run storybook` (dev server) for quick
|
|
13
|
+
iteration.
|
|
14
|
+
|
|
15
|
+
## File location
|
|
16
|
+
|
|
17
|
+
```url
|
|
18
|
+
app/components/<component-folder>/playwright/<component-name>.playwright.ts
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Standard structure
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { test, expect, type Page } from "@playwright/test";
|
|
25
|
+
|
|
26
|
+
// --- Config ---
|
|
27
|
+
const STORYBOOK_URL = "http://127.0.0.1:6006";
|
|
28
|
+
const STORY_BASE = "category-subcategory-componentname"; // see derivation below
|
|
29
|
+
const ELEMENT_TIMEOUT = 15_000;
|
|
30
|
+
|
|
31
|
+
// --- Helpers ---
|
|
32
|
+
const getStoryUrl = (story: string, args: Record<string, string> = {}): string => {
|
|
33
|
+
const argsParam = Object.entries(args)
|
|
34
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
35
|
+
.join(";");
|
|
36
|
+
const url = `${STORYBOOK_URL}/?path=/story/${STORY_BASE}--${story}`;
|
|
37
|
+
return argsParam ? `${url}&args=${argsParam}` : url;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const getElement = async (page: Page, story: string, args: Record<string, string> = {}) => {
|
|
41
|
+
await page.goto(getStoryUrl(story, args), { waitUntil: "load" });
|
|
42
|
+
const frame = page.frameLocator("#storybook-preview-iframe");
|
|
43
|
+
await frame.locator("#storybook-root > *").waitFor({ state: "visible", timeout: ELEMENT_TIMEOUT });
|
|
44
|
+
const el = frame.locator(".component-name");
|
|
45
|
+
await el.waitFor({ state: "visible", timeout: ELEMENT_TIMEOUT });
|
|
46
|
+
return el;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// -------------------------
|
|
50
|
+
// Baseline (default story, default args)
|
|
51
|
+
// -------------------------
|
|
52
|
+
test.describe("ComponentName — baseline", () => {
|
|
53
|
+
test("default matches snapshot", async ({ page }) => {
|
|
54
|
+
const el = await getElement(page, "default");
|
|
55
|
+
await expect(el).toHaveScreenshot("default.png");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// -------------------------
|
|
60
|
+
// Prop variants (one describe per prop, iterate values)
|
|
61
|
+
// -------------------------
|
|
62
|
+
const VARIANTS = ["primary", "secondary", "tertiary"] as const;
|
|
63
|
+
|
|
64
|
+
test.describe("ComponentName — variants", () => {
|
|
65
|
+
for (const variant of VARIANTS) {
|
|
66
|
+
test(`variant-${variant}`, async ({ page }) => {
|
|
67
|
+
const el = await getElement(page, "default", { variant });
|
|
68
|
+
await expect(el).toHaveScreenshot(`variant-${variant}.png`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// -------------------------
|
|
74
|
+
// State snapshots (named stories or arg overrides)
|
|
75
|
+
// -------------------------
|
|
76
|
+
test.describe("ComponentName — states", () => {
|
|
77
|
+
test("with-error", async ({ page }) => {
|
|
78
|
+
const el = await getElement(page, "default", { fieldHasError: "true" });
|
|
79
|
+
await expect(el).toHaveScreenshot("state-error.png");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// -------------------------
|
|
84
|
+
// Cross-variant combinations (meaningful pairs only)
|
|
85
|
+
// -------------------------
|
|
86
|
+
test.describe("ComponentName — combinations", () => {
|
|
87
|
+
const combinations: Array<{ story: string; args: Record<string, string>; name: string }> = [
|
|
88
|
+
{ story: "default", args: { variant: "secondary", theme: "error" }, name: "secondary-error" },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
for (const { story, args, name } of combinations) {
|
|
92
|
+
test(name, async ({ page }) => {
|
|
93
|
+
const el = await getElement(page, story, args);
|
|
94
|
+
await expect(el).toHaveScreenshot(`combo-${name}.png`);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Deriving the STORY_BASE slug
|
|
101
|
+
|
|
102
|
+
Take the story `title` from the `.stories.ts` file, lowercase everything, remove spaces,
|
|
103
|
+
and replace `/` with `-`:
|
|
104
|
+
|
|
105
|
+
| Story `title` | `STORY_BASE` |
|
|
106
|
+
| ------------------------------------------------- | ----------------------------------------------- |
|
|
107
|
+
| `"Atoms/Text Blocks/HeroText"` | `atoms-text-blocks-herotext` |
|
|
108
|
+
| `"Components/Forms/Input Button/InputButtonCore"` | `components-forms-input-button-inputbuttoncore` |
|
|
109
|
+
|
|
110
|
+
The full story URL pattern is:
|
|
111
|
+
|
|
112
|
+
```url
|
|
113
|
+
http://127.0.0.1:6006/?path=/story/<STORY_BASE>--<story-export-name>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Story export names are lowercased and hyphenated: `Default` → `default`,
|
|
117
|
+
`WithError` → `with-error`.
|
|
118
|
+
|
|
119
|
+
## Locating the element
|
|
120
|
+
|
|
121
|
+
Prefer a semantic locator (role) when available, otherwise use the component's root class:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
// By role (preferred for interactive elements)
|
|
125
|
+
const el = frame.getByRole("button");
|
|
126
|
+
|
|
127
|
+
// By class (preferred for display/layout components)
|
|
128
|
+
const el = frame.locator(".component-name");
|
|
129
|
+
|
|
130
|
+
// By test id (if set on the component)
|
|
131
|
+
const el = frame.getByTestId("component-name");
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Always `await el.waitFor({ state: "visible", timeout })` before asserting.
|
|
135
|
+
|
|
136
|
+
## Screenshot naming conventions
|
|
137
|
+
|
|
138
|
+
| Scenario | File name pattern |
|
|
139
|
+
| ------------------------- | ------------------------------------------ |
|
|
140
|
+
| Default story, no args | `default.png` |
|
|
141
|
+
| Single prop variant | `variant-secondary.png`, `theme-error.png` |
|
|
142
|
+
| State override | `state-readonly.png`, `state-pending.png` |
|
|
143
|
+
| Named story baseline | `<story-name>.png` |
|
|
144
|
+
| Cross-variant combination | `combo-secondary-error.png` |
|
|
145
|
+
|
|
146
|
+
## Running tests
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# Requires Storybook running at http://127.0.0.1:6006
|
|
150
|
+
npm run playwright
|
|
151
|
+
|
|
152
|
+
# Update baselines after an intentional visual change
|
|
153
|
+
npm run playwright:update
|
|
154
|
+
|
|
155
|
+
# View the HTML test report
|
|
156
|
+
npx playwright show-report
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Notes
|
|
160
|
+
|
|
161
|
+
- Baselines are stored per browser (Chromium, Firefox, WebKit) — expect three PNG files per
|
|
162
|
+
`toHaveScreenshot()` call.
|
|
163
|
+
- Use `waitUntil: "load"` on `page.goto` for consistency — the iframe needs to fully render.
|
|
164
|
+
- Keep combinations focused on pairs that meaningfully interact visually. Don't exhaustively
|
|
165
|
+
cross every prop — that creates a large, brittle baseline set.
|
|
166
|
+
- Comment out stories in the `STORIES` array rather than deleting them if they're temporarily
|
|
167
|
+
broken, so intent is preserved.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Adding a Unit Test
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Unit tests use Vitest with `mountSuspended` from `@nuxt/test-utils`. They verify props,
|
|
6
|
+
slots, classes, ARIA attributes, and computed internals. Snapshot tests catch unintended
|
|
7
|
+
HTML structure changes.
|
|
8
|
+
|
|
9
|
+
## File location
|
|
10
|
+
|
|
11
|
+
```url
|
|
12
|
+
app/components/<component-folder>/tests/<ComponentName>.spec.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Standard structure
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
19
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
20
|
+
import ComponentName from "../ComponentName.vue";
|
|
21
|
+
|
|
22
|
+
// --- Types (for accessing vm internals) ---
|
|
23
|
+
interface ComponentNameInstance {
|
|
24
|
+
computedProp: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- Helper ---
|
|
28
|
+
const createWrapper = async (props: Record<string, unknown> = {}, slots: Record<string, string> = {}) => {
|
|
29
|
+
return mountSuspended(ComponentName, {
|
|
30
|
+
props: { requiredProp: "default", ...props },
|
|
31
|
+
slots,
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("ComponentName", () => {
|
|
36
|
+
let wrapper: Awaited<ReturnType<typeof createWrapper>>;
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
wrapper?.unmount();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// -------------------------
|
|
43
|
+
// Snapshots
|
|
44
|
+
// -------------------------
|
|
45
|
+
describe("Snapshots", () => {
|
|
46
|
+
it("default", async () => {
|
|
47
|
+
wrapper = await createWrapper();
|
|
48
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("variant-a", async () => {
|
|
52
|
+
wrapper = await createWrapper({ variant: "a" });
|
|
53
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// -------------------------
|
|
58
|
+
// Rendering
|
|
59
|
+
// -------------------------
|
|
60
|
+
describe("Rendering", () => {
|
|
61
|
+
it("mounts without error", async () => {
|
|
62
|
+
wrapper = await createWrapper();
|
|
63
|
+
expect(wrapper.vm).toBeTruthy();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("renders expected element", async () => {
|
|
67
|
+
wrapper = await createWrapper();
|
|
68
|
+
expect(wrapper.find(".component-name").exists()).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// -------------------------
|
|
73
|
+
// Props
|
|
74
|
+
// -------------------------
|
|
75
|
+
describe("Props", () => {
|
|
76
|
+
it("applies variant class", async () => {
|
|
77
|
+
wrapper = await createWrapper({ variant: "secondary" });
|
|
78
|
+
expect(wrapper.find(".component-name").classes()).toContain("secondary");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("applies styleClassPassthrough", async () => {
|
|
82
|
+
wrapper = await createWrapper({ styleClassPassthrough: ["extra"] });
|
|
83
|
+
expect(wrapper.find(".component-name").classes()).toContain("extra");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("applies data-theme attribute", async () => {
|
|
87
|
+
wrapper = await createWrapper({ theme: "secondary" });
|
|
88
|
+
expect(wrapper.find(".component-name").attributes("data-theme")).toBe("secondary");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// -------------------------
|
|
93
|
+
// Accessibility
|
|
94
|
+
// -------------------------
|
|
95
|
+
describe("Accessibility", () => {
|
|
96
|
+
it("has correct aria attribute by default", async () => {
|
|
97
|
+
wrapper = await createWrapper();
|
|
98
|
+
expect(wrapper.find(".component-name").attributes("aria-label")).toBeDefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// -------------------------
|
|
103
|
+
// Slots
|
|
104
|
+
// -------------------------
|
|
105
|
+
describe("Slots", () => {
|
|
106
|
+
it("renders default slot content", async () => {
|
|
107
|
+
wrapper = await createWrapper({}, { default: "<span>Content</span>" });
|
|
108
|
+
expect(wrapper.find("span").exists()).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// -------------------------
|
|
113
|
+
// Computed properties
|
|
114
|
+
// -------------------------
|
|
115
|
+
describe("Computed properties", () => {
|
|
116
|
+
it("computedProp returns expected value", async () => {
|
|
117
|
+
wrapper = await createWrapper({ someProp: "value" });
|
|
118
|
+
expect((wrapper.vm as unknown as ComponentNameInstance).computedProp).toBe("expected");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Key rules
|
|
125
|
+
|
|
126
|
+
- Always `mountSuspended` — never `mount` or `shallowMount` from `@vue/test-utils` directly.
|
|
127
|
+
- Always `afterEach(() => wrapper?.unmount())` to prevent test leaks.
|
|
128
|
+
- Use a `createWrapper` helper to keep individual tests short.
|
|
129
|
+
- Include at least one snapshot test per meaningful visual state.
|
|
130
|
+
|
|
131
|
+
## Snapshot testing
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- Run `npm run test:update` after **intentional** component changes to regenerate snapshots.
|
|
138
|
+
- Never update snapshots to fix a failing test without first verifying the change is intentional.
|
|
139
|
+
- Snapshot files are committed alongside tests.
|
|
140
|
+
|
|
141
|
+
## Accessing component internals (vm)
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
interface ComponentInstance {
|
|
145
|
+
computedProp: string;
|
|
146
|
+
refElement: HTMLElement | null;
|
|
147
|
+
}
|
|
148
|
+
const vm = wrapper.vm as unknown as ComponentInstance;
|
|
149
|
+
expect(vm.computedProp).toBe("expected");
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Testing CSS custom properties
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
const el = wrapper.find(".component-name");
|
|
156
|
+
const style = (el.element as HTMLElement).style;
|
|
157
|
+
expect(style.getPropertyValue("--custom-prop")).toBe("expected-value");
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Mocking browser APIs
|
|
161
|
+
|
|
162
|
+
Mock before the `describe` block if the component uses ResizeObserver, IntersectionObserver, etc.:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const mockResizeObserver = vi.fn(() => ({
|
|
166
|
+
observe: vi.fn(),
|
|
167
|
+
unobserve: vi.fn(),
|
|
168
|
+
disconnect: vi.fn(),
|
|
169
|
+
}));
|
|
170
|
+
vi.stubGlobal("ResizeObserver", mockResizeObserver);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Describe section conventions
|
|
174
|
+
|
|
175
|
+
Use these section names consistently so tests are easy to scan:
|
|
176
|
+
|
|
177
|
+
| Section | What it covers |
|
|
178
|
+
| --------------------- | ---------------------------------------------- |
|
|
179
|
+
| `Snapshots` | `toMatchSnapshot()` per visual state |
|
|
180
|
+
| `Rendering` | mounts, expected elements exist |
|
|
181
|
+
| `Props` | each prop produces the right class/attr/output |
|
|
182
|
+
| `Accessibility` | ARIA attributes, sr-only, roles |
|
|
183
|
+
| `Slots` | named and default slots render correctly |
|
|
184
|
+
| `Computed properties` | `wrapper.vm` internals via interface cast |
|
|
185
|
+
| `Reactivity` | state changes update the DOM as expected |
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Override the Default Theme in a Consuming App
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The layer ships a default blue theme. This skill covers how to replace it with your own colour palette in a consuming Nuxt app — including adding a new colour scale and remapping all semantic tokens for light and dark modes.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- Consuming app has `srcdev-nuxt-components` installed as a Nuxt layer
|
|
10
|
+
- The consuming app has its own CSS entry point (e.g. `app/assets/styles/main.css`) registered in `nuxt.config.ts` via `css: [...]`
|
|
11
|
+
|
|
12
|
+
## Steps
|
|
13
|
+
|
|
14
|
+
### 1. Create the colour scale file
|
|
15
|
+
|
|
16
|
+
Create `app/assets/styles/setup/02.colours/_gold.css` (or your colour name).
|
|
17
|
+
|
|
18
|
+
Colours use the `oklch` colour space. The scale runs from `00` (lightest) to `10` (darkest) — `00` is optional and only needed if you require a near-white tint.
|
|
19
|
+
|
|
20
|
+
```css
|
|
21
|
+
:where(html) {
|
|
22
|
+
--gold-00: oklch(98% 0.01 85);
|
|
23
|
+
--gold-01: oklch(94% 0.04 85);
|
|
24
|
+
--gold-02: oklch(88% 0.09 85);
|
|
25
|
+
--gold-03: oklch(80% 0.14 85);
|
|
26
|
+
--gold-04: oklch(72% 0.18 85);
|
|
27
|
+
--gold-05: oklch(64% 0.20 85);
|
|
28
|
+
--gold-06: oklch(56% 0.19 85);
|
|
29
|
+
--gold-07: oklch(48% 0.17 85);
|
|
30
|
+
--gold-08: oklch(40% 0.15 85);
|
|
31
|
+
--gold-09: oklch(32% 0.12 85);
|
|
32
|
+
--gold-10: oklch(25% 0.09 85);
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Adjust the hue angle (third value) to taste — `85` is a warm gold. Use an oklch colour picker to preview the scale.
|
|
37
|
+
|
|
38
|
+
### 2. Create the colours index
|
|
39
|
+
|
|
40
|
+
Create `app/assets/styles/setup/02.colours/index.css` and import your scale:
|
|
41
|
+
|
|
42
|
+
```css
|
|
43
|
+
@import "./_gold";
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 3. Create the light theme override
|
|
47
|
+
|
|
48
|
+
Create `app/assets/styles/setup/03.theming/default/_light.css`.
|
|
49
|
+
|
|
50
|
+
This file uses `:where(html)` and maps semantic tokens to your colour scale. Copy the full token list from the layer and replace `--blue-*` references with `--gold-*`:
|
|
51
|
+
|
|
52
|
+
```css
|
|
53
|
+
:where(html) {
|
|
54
|
+
/* Color scale */
|
|
55
|
+
--colour-theme-1: var(--gold-01);
|
|
56
|
+
--colour-theme-2: var(--gold-02);
|
|
57
|
+
--colour-theme-3: var(--gold-03);
|
|
58
|
+
--colour-theme-4: var(--gold-04);
|
|
59
|
+
--colour-theme-5: var(--gold-05);
|
|
60
|
+
--colour-theme-6: var(--gold-06);
|
|
61
|
+
--colour-theme-7: var(--gold-07);
|
|
62
|
+
--colour-theme-8: var(--gold-08);
|
|
63
|
+
--colour-theme-9: var(--gold-09);
|
|
64
|
+
--colour-theme-10: var(--gold-10);
|
|
65
|
+
|
|
66
|
+
/* Body */
|
|
67
|
+
--page-bg: var(--slate-00);
|
|
68
|
+
--colour-text-default: var(--slate-09);
|
|
69
|
+
--colour-text-accent: var(--gold-09);
|
|
70
|
+
--colour-text-eyebrow: var(--gold-09);
|
|
71
|
+
|
|
72
|
+
/* Links */
|
|
73
|
+
--colour-link-default: var(--gold-10);
|
|
74
|
+
--colour-link-hover: var(--gold-09);
|
|
75
|
+
|
|
76
|
+
/* Form inputs */
|
|
77
|
+
--theme-input-surface: var(--slate-00);
|
|
78
|
+
--theme-input-surface-hover: var(--slate-01);
|
|
79
|
+
--theme-input-border: var(--gold-06);
|
|
80
|
+
--theme-input-border-hover: var(--gold-05);
|
|
81
|
+
--theme-input-border-focus: var(--gold-04);
|
|
82
|
+
--theme-input-outline: transparent;
|
|
83
|
+
--theme-input-outline-focus: var(--gold-04);
|
|
84
|
+
--theme-input-visible-outline: var(--gold-10);
|
|
85
|
+
--theme-focus-visible-shadow: 0 0 0 2px var(--gold-02);
|
|
86
|
+
--theme-input-placeholder: var(--slate-05);
|
|
87
|
+
--theme-input-text-color-normal: var(--slate-09);
|
|
88
|
+
|
|
89
|
+
/* Checkbox / radio */
|
|
90
|
+
--theme-checkbox-symbol-color: var(--gold-08);
|
|
91
|
+
--theme-checkbox-symbol-surface: var(--theme-input-surface);
|
|
92
|
+
--theme-checkbox-decorator-color: var(--gold-09);
|
|
93
|
+
|
|
94
|
+
/* Toggle */
|
|
95
|
+
--theme-toggle-symbol-color-default: var(--gold-00);
|
|
96
|
+
--theme-toggle-symbol-color-checked: var(--gold-08);
|
|
97
|
+
|
|
98
|
+
/* Buttons — primary */
|
|
99
|
+
--theme-button-primary-surface: var(--gold-09);
|
|
100
|
+
--theme-button-primary-surface-hover: var(--gold-08);
|
|
101
|
+
--theme-button-primary-surface-active: var(--gold-07);
|
|
102
|
+
--theme-button-primary-border: var(--gold-09);
|
|
103
|
+
--theme-button-primary-border-active: var(--gold-09);
|
|
104
|
+
--theme-button-primary-outline: var(--gold-01);
|
|
105
|
+
--theme-button-primary-outline-active: var(--gold-07);
|
|
106
|
+
--theme-button-primary-text: var(--gold-00);
|
|
107
|
+
--theme-button-primary-text-hover: var(--gold-00);
|
|
108
|
+
|
|
109
|
+
/* Buttons — secondary */
|
|
110
|
+
--theme-button-secondary-surface: transparent;
|
|
111
|
+
--theme-button-secondary-surface-hover: var(--gold-01);
|
|
112
|
+
--theme-button-secondary-surface-active: var(--gold-01);
|
|
113
|
+
--theme-button-secondary-border: var(--gold-09);
|
|
114
|
+
--theme-button-secondary-border-active: var(--gold-09);
|
|
115
|
+
--theme-button-secondary-outline: var(--gold-09);
|
|
116
|
+
--theme-button-secondary-outline-active: var(--gold-09);
|
|
117
|
+
--theme-button-secondary-text: var(--gold-09);
|
|
118
|
+
--theme-button-secondary-text-hover: var(--gold-09);
|
|
119
|
+
|
|
120
|
+
/* Buttons — tertiary */
|
|
121
|
+
--theme-button-tertiary-surface: var(--slate-01);
|
|
122
|
+
--theme-button-tertiary-text: var(--gold-09);
|
|
123
|
+
--theme-button-tertiary-border-active: var(--gold-09);
|
|
124
|
+
--theme-button-tertiary-outline-active: var(--gold-09);
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 4. Create the dark theme override
|
|
129
|
+
|
|
130
|
+
Create `app/assets/styles/setup/03.theming/default/_dark.css`.
|
|
131
|
+
|
|
132
|
+
Selector is `:where(html.dark)`. Dark mode typically inverts the scale direction — light values for text/borders, dark values for backgrounds:
|
|
133
|
+
|
|
134
|
+
```css
|
|
135
|
+
:where(html.dark) {
|
|
136
|
+
/* Color scale */
|
|
137
|
+
--colour-theme-1: var(--gold-01);
|
|
138
|
+
--colour-theme-2: var(--gold-02);
|
|
139
|
+
--colour-theme-3: var(--gold-03);
|
|
140
|
+
--colour-theme-4: var(--gold-04);
|
|
141
|
+
--colour-theme-5: var(--gold-05);
|
|
142
|
+
--colour-theme-6: var(--gold-06);
|
|
143
|
+
--colour-theme-7: var(--gold-07);
|
|
144
|
+
--colour-theme-8: var(--gold-08);
|
|
145
|
+
--colour-theme-9: var(--gold-09);
|
|
146
|
+
--colour-theme-10: var(--gold-10);
|
|
147
|
+
|
|
148
|
+
/* Body */
|
|
149
|
+
--page-bg: var(--slate-08);
|
|
150
|
+
--colour-text-default: var(--slate-01);
|
|
151
|
+
--colour-text-accent: var(--gold-05);
|
|
152
|
+
--colour-text-eyebrow: var(--gold-05);
|
|
153
|
+
|
|
154
|
+
/* Links */
|
|
155
|
+
--colour-link-default: var(--gold-03);
|
|
156
|
+
--colour-link-hover: var(--gold-04);
|
|
157
|
+
|
|
158
|
+
/* Form inputs */
|
|
159
|
+
--theme-input-surface: var(--slate-10);
|
|
160
|
+
--theme-input-surface-hover: var(--slate-09);
|
|
161
|
+
--theme-input-border: var(--gold-06);
|
|
162
|
+
--theme-input-border-hover: var(--gold-05);
|
|
163
|
+
--theme-input-border-focus: var(--gold-04);
|
|
164
|
+
--theme-input-outline: var(--gold-06);
|
|
165
|
+
--theme-input-outline-focus: var(--gold-04);
|
|
166
|
+
--theme-input-visible-outline: var(--gold-04);
|
|
167
|
+
--theme-focus-visible-shadow: 0 0 0 2px var(--gold-02);
|
|
168
|
+
--theme-input-placeholder: var(--slate-04);
|
|
169
|
+
--theme-input-text-color-normal: var(--slate-01);
|
|
170
|
+
|
|
171
|
+
/* Checkbox / radio */
|
|
172
|
+
--theme-checkbox-symbol-surface: var(--theme-input-surface);
|
|
173
|
+
--theme-checkbox-symbol-color: var(--gold-02);
|
|
174
|
+
--theme-checkbox-decorator-color: var(--gold-02);
|
|
175
|
+
|
|
176
|
+
/* Buttons — primary */
|
|
177
|
+
--theme-button-primary-border: var(--gold-07);
|
|
178
|
+
--theme-button-primary-border-active: var(--gold-07);
|
|
179
|
+
--theme-button-primary-text: var(--gold-00);
|
|
180
|
+
--theme-button-primary-text-hover: var(--gold-00);
|
|
181
|
+
|
|
182
|
+
/* Buttons — secondary */
|
|
183
|
+
--theme-button-secondary-surface: var(--gold-01);
|
|
184
|
+
--theme-button-secondary-border-active: var(--gold-01);
|
|
185
|
+
--theme-button-secondary-text: var(--gold-09);
|
|
186
|
+
|
|
187
|
+
/* Buttons — tertiary */
|
|
188
|
+
--theme-button-tertiary-surface: transparent;
|
|
189
|
+
--theme-button-tertiary-text: var(--gold-01);
|
|
190
|
+
--theme-button-tertiary-border-active: var(--gold-01);
|
|
191
|
+
--theme-button-tertiary-outline-active: var(--gold-09);
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### 5. Create the theming index
|
|
196
|
+
|
|
197
|
+
Create `app/assets/styles/setup/03.theming/default/index.css`:
|
|
198
|
+
|
|
199
|
+
```css
|
|
200
|
+
@import "./_light";
|
|
201
|
+
@import "./_dark";
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### 6. Create the setup index
|
|
205
|
+
|
|
206
|
+
Create `app/assets/styles/setup/index.css` that imports colours then theming (order matters — colours must be defined before theming references them):
|
|
207
|
+
|
|
208
|
+
```css
|
|
209
|
+
@import "./02.colours/";
|
|
210
|
+
@import "./03.theming/default/";
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### 7. Wire up the CSS entry point
|
|
214
|
+
|
|
215
|
+
In your consuming app's `app/assets/styles/main.css` (or equivalent), import your setup after the layer's styles are applied:
|
|
216
|
+
|
|
217
|
+
```css
|
|
218
|
+
@import "./setup/";
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Then register it in `nuxt.config.ts`:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
export default defineNuxtConfig({
|
|
225
|
+
extends: "srcdev-nuxt-components",
|
|
226
|
+
css: ["~/assets/styles/main.css"],
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The consuming app's CSS loads after the layer's, so your token overrides win via the cascade.
|
|
231
|
+
|
|
232
|
+
## Notes
|
|
233
|
+
|
|
234
|
+
- The `--slate-*` scale comes from the layer and does not need to be redefined — keep all neutral/background tokens pointing at `--slate-*`.
|
|
235
|
+
- Only redefine tokens that change. You do not need to copy tokens that stay identical between layer and your override.
|
|
236
|
+
- Dark mode is triggered by the `dark` class on `<html>`. The layer handles toggling this via `data-color-scheme` and the colour scheme store.
|
|
237
|
+
- If you need additional component-specific tokens (e.g. `--stepper-list-*`, `--glass-panel-*`), add them to `_light.css` and `_dark.css` following the same pattern.
|
|
238
|
+
- Use an oklch colour picker (e.g. oklch.com) to build and preview your scale before committing values.
|
package/README.md
CHANGED
|
@@ -20,6 +20,26 @@ defineNuxtConfig({
|
|
|
20
20
|
});
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
## Claude Code Skills
|
|
24
|
+
|
|
25
|
+
This package ships Claude Code skills — reference docs for components and development tasks — in the `.claude/` directory.
|
|
26
|
+
|
|
27
|
+
To make them available in your project, add this script to your `package.json`:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
"setup:claude": "cp -r node_modules/srcdev-nuxt-components/.claude/skills .claude/skills/srcdev-nuxt-components"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then run it after install:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm run setup:claude
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Skills are copied into `.claude/skills/srcdev-nuxt-components/` so they never conflict with or overwrite skills your own project defines. Re-running the script after a package update is safe.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
23
43
|
## Known Dev Server Warnings
|
|
24
44
|
|
|
25
45
|
### `[request error] [GET] http://localhost:3000/_nuxt/` (404)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<NuxtLink class="link-text" :class="elementClasses" :to="to" :external="external" :target="target">
|
|
3
|
+
<span v-if="hasLeftSlot" class="link-text__icon link-text__icon--left">
|
|
4
|
+
<slot name="left"></slot>
|
|
5
|
+
</span>
|
|
6
|
+
<span class="link-text__label">{{ linkText }}</span>
|
|
7
|
+
<span v-if="hasRightSlot" class="link-text__icon link-text__icon--right">
|
|
8
|
+
<slot name="right"></slot>
|
|
9
|
+
</span>
|
|
10
|
+
</NuxtLink>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
interface Props {
|
|
15
|
+
to: string;
|
|
16
|
+
linkText: string;
|
|
17
|
+
external?: boolean;
|
|
18
|
+
target?: string;
|
|
19
|
+
styleClassPassthrough?: string | string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
23
|
+
external: false,
|
|
24
|
+
target: undefined,
|
|
25
|
+
styleClassPassthrough: () => [],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const slots = useSlots();
|
|
29
|
+
const hasLeftSlot = computed(() => Boolean(slots.left));
|
|
30
|
+
const hasRightSlot = computed(() => Boolean(slots.right));
|
|
31
|
+
|
|
32
|
+
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<style lang="css">
|
|
36
|
+
@layer components {
|
|
37
|
+
.link-text {
|
|
38
|
+
display: inline-grid;
|
|
39
|
+
grid-auto-flow: column;
|
|
40
|
+
align-items: center;
|
|
41
|
+
gap: var(--link-text-gap, 0.4em);
|
|
42
|
+
color: var(--link-text-colour, currentColor);
|
|
43
|
+
font-size: var(--link-text-font-size, inherit);
|
|
44
|
+
text-decoration: var(--link-text-decoration, underline);
|
|
45
|
+
text-underline-offset: var(--link-text-underline-offset, 0.2em);
|
|
46
|
+
transition: color var(--control-transition-duration, 200ms) var(--control-transition-ease, ease);
|
|
47
|
+
|
|
48
|
+
&:hover,
|
|
49
|
+
&:focus-visible {
|
|
50
|
+
color: var(--link-text-colour-hover, currentColor);
|
|
51
|
+
text-decoration: var(--link-text-decoration-hover, none);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
&:focus-visible {
|
|
55
|
+
outline: 2px solid var(--link-text-colour, currentColor);
|
|
56
|
+
outline-offset: 3px;
|
|
57
|
+
border-radius: 2px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.link-text__icon {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
</style>
|