@schalkneethling/toolkit 0.5.0 → 0.5.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.
Files changed (26) hide show
  1. package/dist/index.mjs.map +1 -1
  2. package/hooks/auto-approve-safe-commands/hook.mjs +5 -1
  3. package/hooks/auto-approve-safe-commands/hook.mts +7 -6
  4. package/hooks/block-dangerous-commands/hook.mjs +3 -3
  5. package/hooks/block-dangerous-commands/hook.mts +10 -22
  6. package/package.json +9 -9
  7. package/skills/css-tokens/SKILL.md +1 -1
  8. package/skills/css-tokens/references/tokens.css +6 -10
  9. package/skills/frontend-security/SKILL.md +3 -0
  10. package/skills/frontend-security/references/csp-configuration.md +68 -51
  11. package/skills/frontend-security/references/csrf-protection.md +74 -70
  12. package/skills/frontend-security/references/dom-security.md +36 -29
  13. package/skills/frontend-security/references/file-upload-security.md +101 -69
  14. package/skills/frontend-security/references/framework-patterns.md +42 -40
  15. package/skills/frontend-security/references/input-validation.md +36 -31
  16. package/skills/frontend-security/references/jwt-security.md +68 -84
  17. package/skills/frontend-security/references/nodejs-npm-security.md +63 -55
  18. package/skills/frontend-security/references/xss-prevention.md +38 -36
  19. package/skills/frontend-testing/SKILL.md +31 -38
  20. package/skills/frontend-testing/references/accessibility-testing.md +56 -62
  21. package/skills/frontend-testing/references/aria-snapshots.md +35 -34
  22. package/skills/frontend-testing/references/locator-strategies.md +37 -40
  23. package/skills/frontend-testing/references/visual-regression.md +29 -23
  24. package/skills/npm-publishing-best-practices/SKILL.md +316 -0
  25. package/skills/semantic-html/SKILL.md +5 -21
  26. package/skills/semantic-html/references/heading-patterns.md +1 -5
@@ -5,6 +5,7 @@ Visual regression testing (VRT) catches unintended visual changes by comparing s
5
5
  ## When to Use VRT
6
6
 
7
7
  **Good candidates:**
8
+
8
9
  - Design system components
9
10
  - Critical landing pages
10
11
  - Complex layouts (grids, dashboards)
@@ -12,6 +13,7 @@ Visual regression testing (VRT) catches unintended visual changes by comparing s
12
13
  - Cross-browser visual consistency
13
14
 
14
15
  **Poor candidates:**
16
+
15
17
  - Pages with frequently changing dynamic content
16
18
  - Components with animations
17
19
  - Time-dependent displays
@@ -26,7 +28,7 @@ import { test, expect } from "@playwright/test";
26
28
 
27
29
  test("homepage looks correct", async ({ page }) => {
28
30
  await page.goto("/");
29
-
31
+
30
32
  await expect(page).toHaveScreenshot();
31
33
  });
32
34
  ```
@@ -38,7 +40,7 @@ First run creates the baseline. Subsequent runs compare against it.
38
40
  ```javascript
39
41
  test("hero section renders correctly", async ({ page }) => {
40
42
  await page.goto("/");
41
-
43
+
42
44
  await expect(page).toHaveScreenshot("homepage-hero.png");
43
45
  });
44
46
  ```
@@ -50,7 +52,7 @@ More stable than full-page screenshots:
50
52
  ```javascript
51
53
  test("product card renders correctly", async ({ page }) => {
52
54
  await page.goto("/products/123");
53
-
55
+
54
56
  const productCard = page.getByTestId("product-card");
55
57
  await expect(productCard).toHaveScreenshot("product-card.png");
56
58
  });
@@ -61,7 +63,7 @@ test("product card renders correctly", async ({ page }) => {
61
63
  ```javascript
62
64
  test("full page layout", async ({ page }) => {
63
65
  await page.goto("/about");
64
-
66
+
65
67
  await expect(page).toHaveScreenshot("about-page-full.png", {
66
68
  fullPage: true,
67
69
  });
@@ -77,13 +79,13 @@ Visual tests fail due to timing, rendering, or environment differences. Stabiliz
77
79
  ```javascript
78
80
  test("page renders after loading", async ({ page }) => {
79
81
  await page.goto("/");
80
-
82
+
81
83
  // Wait for network to be idle
82
84
  await page.waitForLoadState("networkidle");
83
-
85
+
84
86
  // Wait for web fonts to load
85
87
  await page.evaluate(() => document.fonts.ready);
86
-
88
+
87
89
  await expect(page).toHaveScreenshot();
88
90
  });
89
91
  ```
@@ -107,7 +109,7 @@ Or in test:
107
109
  ```javascript
108
110
  test("static screenshot", async ({ page }) => {
109
111
  await page.goto("/");
110
-
112
+
111
113
  // Inject CSS to disable animations
112
114
  await page.addStyleTag({
113
115
  content: `
@@ -117,7 +119,7 @@ test("static screenshot", async ({ page }) => {
117
119
  }
118
120
  `,
119
121
  });
120
-
122
+
121
123
  await expect(page).toHaveScreenshot();
122
124
  });
123
125
  ```
@@ -129,7 +131,7 @@ Hide elements that change between runs:
129
131
  ```javascript
130
132
  test("page with masked dynamic content", async ({ page }) => {
131
133
  await page.goto("/dashboard");
132
-
134
+
133
135
  await expect(page).toHaveScreenshot({
134
136
  mask: [
135
137
  page.locator("[data-testid='current-time']"),
@@ -187,9 +189,9 @@ Allow minor pixel differences to reduce flakiness.
187
189
  export default defineConfig({
188
190
  expect: {
189
191
  toHaveScreenshot: {
190
- maxDiffPixels: 100, // Allow up to 100 different pixels
191
- maxDiffPixelRatio: 0.01, // Or 1% of total pixels
192
- threshold: 0.2, // Color difference threshold (0-1)
192
+ maxDiffPixels: 100, // Allow up to 100 different pixels
193
+ maxDiffPixelRatio: 0.01, // Or 1% of total pixels
194
+ threshold: 0.2, // Color difference threshold (0-1)
193
195
  },
194
196
  },
195
197
  });
@@ -206,14 +208,14 @@ await expect(page).toHaveScreenshot({
206
208
 
207
209
  ### Threshold Guidelines
208
210
 
209
- | Setting | Value | Use Case |
210
- |---------|-------|----------|
211
- | `threshold` | 0.1 | Strict pixel matching |
212
- | `threshold` | 0.2 | Standard tolerance (default) |
213
- | `threshold` | 0.3 | Generous for anti-aliasing |
214
- | `maxDiffPixels` | 50 | Small components |
215
- | `maxDiffPixels` | 200 | Full pages |
216
- | `maxDiffPixelRatio` | 0.01 | 1% tolerance |
211
+ | Setting | Value | Use Case |
212
+ | ------------------- | ----- | ---------------------------- |
213
+ | `threshold` | 0.1 | Strict pixel matching |
214
+ | `threshold` | 0.2 | Standard tolerance (default) |
215
+ | `threshold` | 0.3 | Generous for anti-aliasing |
216
+ | `maxDiffPixels` | 50 | Small components |
217
+ | `maxDiffPixels` | 200 | Full pages |
218
+ | `maxDiffPixelRatio` | 0.01 | 1% tolerance |
217
219
 
218
220
  ## Updating Baselines
219
221
 
@@ -290,6 +292,7 @@ export default defineConfig({
290
292
  ### OS Differences
291
293
 
292
294
  Font rendering varies by OS. Options:
295
+
293
296
  - Run visual tests in CI only (consistent environment)
294
297
  - Use Docker with consistent fonts
295
298
  - Generate baselines in CI, not locally
@@ -301,7 +304,7 @@ Font rendering varies by OS. Options:
301
304
  ```yaml
302
305
  - name: Run visual tests
303
306
  run: npx playwright test --project=chromium
304
-
307
+
305
308
  - name: Upload diff artifacts on failure
306
309
  if: failure()
307
310
  uses: actions/upload-artifact@v7
@@ -317,7 +320,7 @@ For consistent baselines, generate in CI:
317
320
  ```yaml
318
321
  - name: Update baselines
319
322
  run: npx playwright test --update-snapshots
320
-
323
+
321
324
  - name: Commit baselines
322
325
  run: |
323
326
  git config user.name "CI Bot"
@@ -360,6 +363,7 @@ npx playwright show-report
360
363
  ```
361
364
 
362
365
  Shows:
366
+
363
367
  - Expected (baseline) image
364
368
  - Actual (current) image
365
369
  - Diff highlighting changed pixels
@@ -398,12 +402,14 @@ This seems appealing for "cleaner" snapshot names, but it breaks visual testing.
398
402
  > "Different snapshots are needed for different browsers and platforms due to variations in rendering and fonts."
399
403
 
400
404
  **Why it fails:**
405
+
401
406
  - Font rendering differs between macOS, Windows, and Linux
402
407
  - Anti-aliasing algorithms vary by OS and browser
403
408
  - Subpixel rendering produces different results
404
409
  - You'll get constant false positives or false negatives
405
410
 
406
411
  **Keep the default naming:**
412
+
407
413
  ```
408
414
  button-chromium-darwin.png
409
415
  button-chromium-linux.png
@@ -0,0 +1,316 @@
1
+ ---
2
+ name: npm-package-publishing
3
+ description: >
4
+ Apply best practices when publishing npm packages, including secure CI/CD workflows, trusted
5
+ publishing via OIDC, GitHub repository hardening, and supply-chain attack prevention. Use this
6
+ skill whenever the user asks about publishing an npm package, setting up a publish workflow,
7
+ configuring GitHub Actions for release automation, managing npm tokens or secrets, setting up
8
+ changesets, or auditing an existing publishing pipeline for security. Also trigger when the user
9
+ mentions publint, OIDC trusted publishing, release automation, or package versioning workflows.
10
+ ---
11
+
12
+ # npm Package Publishing — Best Practices
13
+
14
+ Based on the [e18e publishing guide](https://e18e.dev/docs/publishing.html). Reference it for
15
+ the canonical source; this skill distils the actionable steps.
16
+
17
+ > **Package manager note.** All examples in this skill use `npm` to match the e18e source
18
+ > material, but nothing here is npm-specific. Always use whichever package manager the project
19
+ > already uses — `pnpm`, `yarn`, `bun`, etc. Adapt commands accordingly:
20
+ >
21
+ > | npm | pnpm | yarn |
22
+ > | --------------------------------- | ------------------------------------------------- | ------------------------------------------------- |
23
+ > | `npm ci --ignore-scripts` | `pnpm install --frozen-lockfile --ignore-scripts` | `yarn install --frozen-lockfile --ignore-scripts` |
24
+ > | `npm i -g npm` | `pnpm add -g pnpm` | `yarn set version stable` |
25
+ > | `ignore-scripts=true` in `.npmrc` | `ignore-scripts=true` in `.npmrc` | `enableScripts: false` in `.yarnrc.yml` |
26
+ >
27
+ > Detect the project's package manager by checking for a lockfile (`pnpm-lock.yaml`,
28
+ > `yarn.lock`, `bun.lockb`) or a `packageManager` field in `package.json` before
29
+ > generating any commands or workflow steps.
30
+
31
+ ---
32
+
33
+ ## 1 · Prerequisites
34
+
35
+ ### 1.1 · Enforce 2FA everywhere
36
+
37
+ | Account | Location | Recommended method |
38
+ | ------- | -------------------------------------- | ---------------------------------------------------------------------------- |
39
+ | npm | Account → Security | Security key (YubiKey, Touch ID, Windows Hello); fallback: authenticator app |
40
+ | GitHub | Settings → Password and authentication | Same priority order |
41
+
42
+ Use a password manager with generated passwords for both accounts.
43
+
44
+ ### 1.2 · Harden GitHub Actions settings
45
+
46
+ `Settings → Actions → General`:
47
+
48
+ - ✅ Require actions to be pinned to a **full-length commit SHA**
49
+ - ✅ Require approval for first-time contributors
50
+ - ✅ Set default workflow permissions to **"Read repository contents and packages"**
51
+
52
+ > If the repository belongs to an organisation, apply these settings at org level for consistency
53
+ > across all repositories.
54
+
55
+ ### 1.3 · Configure branch protection
56
+
57
+ `Settings → Branches` → create a ruleset for `main`:
58
+
59
+ - ✅ Require a pull request before merging
60
+ - ✅ Require at least 1 approval
61
+ - ✅ Dismiss stale approvals when new commits are pushed
62
+ - ✅ Require approval of the most recent reviewable push
63
+
64
+ ### 1.4 · Remove legacy npm tokens
65
+
66
+ `Settings → Secrets & Variables → Actions`: remove any stored npm tokens. OIDC trusted publishing
67
+ replaces them entirely — no long-lived secrets needed in the repository.
68
+
69
+ ---
70
+
71
+ ## 2 · Trusted Publishing (OIDC)
72
+
73
+ Trusted publishing means GitHub Actions authenticates directly with npm via OIDC — no npm token
74
+ ever touches the repository.
75
+
76
+ ### 2.1 · Configure on npmjs.com
77
+
78
+ 1. Open your package page → **Settings** tab → **Trusted Publishing** section.
79
+ 2. Add a trusted publisher:
80
+ - **Organisation / user**: your GitHub org or username
81
+ - **Repository**: the repository name
82
+ - **Workflow filename**: e.g. `publish.yml`
83
+ 3. Check **"Require two-factor authentication and disallow tokens"** — this forces manual publishes
84
+ to use 2FA as well.
85
+
86
+ > For bulk configuration across many packages, use
87
+ > [`open-packages-on-npm`](https://github.com/antfu/open-packages-on-npm) to open each package
88
+ > in a new tab, then the
89
+ > [npm-trusted-publisher userscript](https://github.com/sxzz/userscripts/blob/main/src/npm-trusted-publisher.md)
90
+ > to configure them rapidly.
91
+
92
+ ### 2.2 · npm CLI version requirement
93
+
94
+ The publish step **must** use npm CLI ≥ 11.5.1 for automatic OIDC trusted publishing.
95
+ Node.js 24 bundles npm 11.5.1; with older CI images, add a step before publishing:
96
+
97
+ ```yaml
98
+ - run: npm i -g npm
99
+ ```
100
+
101
+ ---
102
+
103
+ ## 3 · Standard Publish Workflow
104
+
105
+ Use the [e18e setup-publish template](https://github.com/e18e/setup-publish/blob/main/templates/default.yml)
106
+ as your base, or scaffold it with:
107
+
108
+ ```bash
109
+ npx @e18e/setup-publish
110
+ ```
111
+
112
+ ### 3.1 · Job structure
113
+
114
+ The workflow **must** separate build from publish. This ensures publish permissions (the OIDC
115
+ token) are never exposed to build-time code.
116
+
117
+ ```
118
+ test → build → publish
119
+ ```
120
+
121
+ ### 3.2 · Non-negotiable workflow constraints
122
+
123
+ | Constraint | Why |
124
+ | ------------------------------------------------------ | ----------------------------------------------------------- |
125
+ | All actions pinned to full-length commit SHA | Prevents supply-chain attacks via action updates |
126
+ | `npm ci --ignore-scripts` (or `--ignore-scripts` flag) | Prevents malicious lifecycle scripts running during install |
127
+ | Build and publish in **separate jobs** | Isolates publish permissions from arbitrary build code |
128
+
129
+ ### 3.3 · Suppress lifecycle scripts project-wide
130
+
131
+ Add to `.npmrc` in the repository:
132
+
133
+ ```
134
+ ignore-scripts=true
135
+ ```
136
+
137
+ Also apply globally on developer machines:
138
+
139
+ ```bash
140
+ npm config set -g ignore-scripts true
141
+ ```
142
+
143
+ ### 3.4 · Creating a release
144
+
145
+ ```bash
146
+ git tag v1.0.0
147
+ git push origin v1.0.0
148
+ ```
149
+
150
+ Then in GitHub UI: **Releases → Draft a new release** → choose the tag → **Generate release notes**
151
+ → **Publish release**. This triggers the workflow.
152
+
153
+ ---
154
+
155
+ ## 4 · Alternative Workflow Strategies
156
+
157
+ ### 4.1 · Changesets (recommended for teams)
158
+
159
+ Use the [changesets template](https://github.com/e18e/setup-publish/blob/main/templates/changesets.yml).
160
+
161
+ - Merged PRs automatically update a release pull request
162
+ - Changelog is generated by changesets and included in the release PR
163
+ - Releasing = merging the generated release PR — no manual tagging
164
+
165
+ ### 4.2 · changelogithub (changelog from commit messages)
166
+
167
+ Use the [changelogithub template](https://github.com/e18e/setup-publish/blob/main/templates/changelogithub.yml).
168
+
169
+ - Tags are still pushed manually
170
+ - GitHub release + changelog are created automatically on tag push
171
+ - Package is published on tag push
172
+
173
+ ---
174
+
175
+ ## 5 · Ongoing Maintenance
176
+
177
+ ### 5.1 · Keep dependencies updated
178
+
179
+ Set up **Dependabot** or **Renovate** to receive automated PRs for dependency updates,
180
+ addressing security vulnerabilities promptly.
181
+
182
+ ### 5.2 · Keep GitHub Actions updated
183
+
184
+ All actions must be pinned to a commit SHA (not a tag). To migrate existing workflows and keep
185
+ them current:
186
+
187
+ ```bash
188
+ npx actions-up
189
+ ```
190
+
191
+ Run this periodically, or let Dependabot/Renovate manage action updates once SHAs are in place.
192
+
193
+ ### 5.3 · Lint workflows for vulnerabilities
194
+
195
+ [`zizmor`](https://github.com/zizmorcore/zizmor) detects template injection vulnerabilities and
196
+ excessive permission scopes in GitHub workflow files:
197
+
198
+ ```bash
199
+ zizmor .github/workflows/publish.yml
200
+ ```
201
+
202
+ Integrate this into CI or run it before merging workflow changes.
203
+
204
+ ### 5.4 · Validate package.json and exports
205
+
206
+ [`publint`](https://publint.dev) checks for common publishing issues: missing files, incorrect
207
+ `exports` fields, wrong `main`/`module` paths, and more.
208
+
209
+ ```bash
210
+ npx publint
211
+ ```
212
+
213
+ Review the [full list of publint rules](https://publint.dev/rules) to understand what it checks.
214
+ Run this locally before tagging a release.
215
+
216
+ ### 5.5 · Visualise dependency changes
217
+
218
+ [`multiocular`](https://github.com/multiocular-com/multiocular) shows exactly what code changed
219
+ between dependency versions, helping catch unexpected changes or potential security issues.
220
+
221
+ ---
222
+
223
+ ## 6 · Further Security Hardening
224
+
225
+ ### 6.1 · Use a GitHub environment (important for shared repos)
226
+
227
+ Without a GitHub environment, **any account with write access can trigger a publish** by creating
228
+ a branch and modifying the release workflow — bypassing code review entirely.
229
+
230
+ `Settings → Environments`:
231
+
232
+ 1. Create an environment named `publish`.
233
+ 2. **Do not** allow administrator bypass of protection rules.
234
+ 3. Limit deployment to explicit branch names only (e.g. `main`, `v1`). Do not use wildcards.
235
+ Remove stale branches promptly.
236
+ 4. Update the publish job in `publish.yml`:
237
+
238
+ ```yaml
239
+ jobs:
240
+ publish:
241
+ environment: publish
242
+ ```
243
+
244
+ 5. Update the npm trusted publisher settings on `npmjs.com` to include the environment name.
245
+
246
+ > Optionally configure the `publish` environment to require **manual approval** before the job
247
+ > proceeds — providing a human gate even if malicious code reaches a release branch.
248
+
249
+ ### 6.2 · Consider hardware security keys _(optional)_
250
+
251
+ Physical security keys (YubiKey, etc.) are significantly more resistant to phishing and credential
252
+ theft than authenticator apps or SMS. They are worth recommending but should never be enforced —
253
+ not everyone has access to one, and a good authenticator app is a perfectly reasonable alternative.
254
+ Mention this as a suggestion, not a requirement.
255
+
256
+ ### 6.3 · Protect all long-lived branches and tags
257
+
258
+ Apply branch protection rules not just to `main` but to all long-lived branches and to all tags.
259
+
260
+ ### 6.4 · Enable immutable releases
261
+
262
+ Enable [GitHub immutable releases](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases)
263
+ to prevent modification or deletion of tags and releases after creation.
264
+
265
+ ---
266
+
267
+ ## 7 · Sole Maintainer Considerations
268
+
269
+ There is an [open feature request](https://github.com/orgs/community/discussions/174507) to
270
+ support 2FA for GitHub environment approvals. Until that lands, trusted publishing carries a risk
271
+ for solo maintainers: a leaked GitHub token could be used to publish through the trusted workflow
272
+ without a 2FA challenge.
273
+
274
+ **Recommendation for sole maintainers**: consider publishing locally with `npm publish` and
275
+ 2FA-protected npm access until environment-level 2FA approval is supported. Continue to follow
276
+ all other security recommendations in this document regardless.
277
+
278
+ ---
279
+
280
+ ## Quick Reference Checklist
281
+
282
+ Use this when setting up a new package or auditing an existing one.
283
+
284
+ ### Account security
285
+
286
+ - [ ] npm 2FA enabled (security key preferred)
287
+ - [ ] GitHub 2FA enabled (security key preferred)
288
+ - [ ] Password manager in use
289
+
290
+ ### Repository settings
291
+
292
+ - [ ] Actions pinned to full-length commit SHAs (enforced in settings)
293
+ - [ ] First-time contributor approval required
294
+ - [ ] Default workflow permissions set to read-only
295
+ - [ ] `main` branch protected (PR + review required)
296
+ - [ ] No npm tokens in repository secrets
297
+
298
+ ### Trusted publishing
299
+
300
+ - [ ] OIDC trusted publisher configured on npmjs.com
301
+ - [ ] "Require 2FA, disallow tokens" enabled on npm
302
+ - [ ] Publish step uses Node.js ≥ 24.8.0
303
+ - [ ] GitHub environment (`publish`) configured with branch restrictions
304
+
305
+ ### Workflow hygiene
306
+
307
+ - [ ] Build and publish are separate jobs
308
+ - [ ] `npm ci --ignore-scripts` used in all install steps
309
+ - [ ] `ignore-scripts=true` in `.npmrc`
310
+ - [ ] `zizmor` passes on all workflow files
311
+
312
+ ### Package quality
313
+
314
+ - [ ] `npx publint` passes with no errors
315
+ - [ ] Dependabot or Renovate configured
316
+ - [ ] `actions-up` run to migrate to SHA-pinned actions
@@ -209,9 +209,7 @@ When in doubt: if the content serves the primary purpose of the page, it belongs
209
209
  ```html
210
210
  <!-- Correct: pull quote from the article's own content -->
211
211
  <aside aria-label="Pull quote">
212
- <p>
213
- "The biggest gains came not from new features, but from removing old ones."
214
- </p>
212
+ <p>"The biggest gains came not from new features, but from removing old ones."</p>
215
213
  </aside>
216
214
 
217
215
  <!-- Use blockquote for genuine external quotations -->
@@ -462,9 +460,7 @@ HTML's `required` attribute communicates required state to assistive technology,
462
460
  ```html
463
461
  <!-- Pattern: asterisk with legend explaining it -->
464
462
  <fieldset>
465
- <legend>
466
- Contact details <span aria-hidden="true">*</span> required fields
467
- </legend>
463
+ <legend>Contact details <span aria-hidden="true">*</span> required fields</legend>
468
464
 
469
465
  <label for="name">Full name <span aria-hidden="true">*</span></label>
470
466
  <input type="text" id="name" required />
@@ -486,12 +482,7 @@ When inputs have format hints or helper text, associate them with the input via
486
482
  Multiple associations are allowed—comma-separated IDs work for both hint and error:
487
483
 
488
484
  ```html
489
- <input
490
- type="email"
491
- id="email"
492
- aria-invalid="true"
493
- aria-describedby="email-hint email-error"
494
- />
485
+ <input type="email" id="email" aria-invalid="true" aria-describedby="email-hint email-error" />
495
486
  ```
496
487
 
497
488
  ### Error Messages
@@ -505,15 +496,8 @@ Current best practice (due to browser support gaps with `aria-errormessage`):
505
496
 
506
497
  ```html
507
498
  <label for="email">Email</label>
508
- <input
509
- type="email"
510
- id="email"
511
- aria-invalid="true"
512
- aria-describedby="email-error"
513
- />
514
- <p id="email-error" class="error">
515
- Enter a valid email address, like name@example.com
516
- </p>
499
+ <input type="email" id="email" aria-invalid="true" aria-describedby="email-error" />
500
+ <p id="email-error" class="error">Enter a valid email address, like name@example.com</p>
517
501
  ```
518
502
 
519
503
  ## Tables
@@ -139,11 +139,7 @@ function Card({ title, headingLevel = 3, headingClass, children }) {
139
139
  // Specialised product card - knows its context
140
140
  function ProductCard({ product, headingLevel = 3 }) {
141
141
  return (
142
- <Card
143
- title={product.name}
144
- headingLevel={headingLevel}
145
- headingClass="product-card__title"
146
- >
142
+ <Card title={product.name} headingLevel={headingLevel} headingClass="product-card__title">
147
143
  <p className="product-card__price">{product.price}</p>
148
144
  <p className="product-card__description">{product.description}</p>
149
145
  </Card>