feat(site): phase-2 — New badge, technique facet, support notes, 150-line limit, new categories
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 8 changed files with +101 and −10
modified
README.md
+15 −3
@@ -30,11 +30,23 @@ Snippet hard rules (enforced by `scripts/validate-snippets.mjs`, which **fails t | ||
| 30 | 30 | |
| 31 | 31 | - SVGarden author header at the top (attribution travels with every copy/download); |
| 32 | 32 | - `<!--svgarden ... -->` YAML frontmatter (title, slug, category, tags, difficulty, |
| 33 | − techniques, how_it_works, customizable, created); | |
| 33 | + techniques, how_it_works, customizable, created, and optional `support`); | |
| 34 | 34 | - self-contained (no external assets), all classes prefixed `sg-<slug>`, |
| 35 | 35 | - customizable values as `--sg-*` custom properties declared on the root element, |
| 36 | −- ≤ 120 lines, `js` tag iff the snippet contains a `<script>`, | |
| 37 | −- every `<svg>` is `aria-hidden="true"` or `role="img"` + `<title>`. | |
| 36 | +- ≤ 150 lines, `js` tag iff the snippet contains a `<script>`, | |
| 37 | +- every `<svg>` is `aria-hidden="true"` or `role="img"` + `<title>`; | |
| 38 | +- cutting-edge CSS (`@property`, `animation-timeline`, `:has()`, `backdrop-filter`, | |
| 39 | + `linear()`, CSS `d`) must sit behind `@supports` with a graceful static fallback, | |
| 40 | + **and** declare a `support: <one-line caveat>` frontmatter field — the detail page | |
| 41 | + renders it as a browser-support note. | |
| 42 | + | |
| 43 | +### Categories | |
| 44 | + | |
| 45 | +`loaders` · `stroke-draw` · `hover` · `gauges` · `text` · `text-fx` (masterclass text | |
| 46 | +effects) · `morph` · `motion-path` (offset-path / animateMotion) · `filters` | |
| 47 | +(feTurbulence, goo, displacement…) · `scroll` (scroll-driven animations, self-contained | |
| 48 | +scroll boxes) · `charts` (animated data-viz primitives) · `interactive` (state-driven, | |
| 49 | +checkbox/radio/details-powered) · `backgrounds` · `buttons` | |
| 38 | 50 | |
| 39 | 51 | ## Architecture |
| 40 | 52 | |
modified
scripts/validate-snippets.mjs
+9 −2
@@ -16,7 +16,9 @@ import { | ||
| 16 | 16 | parseSnippetSource, |
| 17 | 17 | } from '../src/lib/snippets.mjs'; |
| 18 | 18 | |
| 19 | −const MAX_LINES = 120; | |
| 19 | +// Raised from 120 → 150 for the phase-2 "next-level" batch (user directive | |
| 20 | +// 2026-08-10, overrides CLAUDE.md §5's ~120 — conflict flagged in that session). | |
| 21 | +const MAX_LINES = 150; | |
| 20 | 22 | const errors = []; |
| 21 | 23 | const fail = (msg) => errors.push(msg); |
| 22 | 24 | |
@@ -109,8 +111,13 @@ for (const rel of files) { | ||
| 109 | 111 | } |
| 110 | 112 | } |
| 111 | 113 | |
| 112 | − // Pedagogy: how_it_works must genuinely explain (2–4 sentences ≈ length bounds) | |
| 114 | + // Pedagogy: how_it_works must genuinely explain (2–5 sentences ≈ length bounds) | |
| 113 | 115 | if (s.howItWorks.length < 120) fail(`${relPath}: how_it_works too short to teach anything (${s.howItWorks.length} chars)`); |
| 116 | + | |
| 117 | + // support (optional): must be a real caveat sentence when present, and any | |
| 118 | + // @supports-guarded feature should declare one | |
| 119 | + if (s.support !== null && s.support.length < 15) fail(`${relPath}: support field present but too short to inform`); | |
| 120 | + if (/@supports/.test(s.code) && !s.support) fail(`${relPath}: uses @supports but frontmatter has no support caveat`); | |
| 114 | 121 | } |
| 115 | 122 | |
| 116 | 123 | if (errors.length) { |
modified
src/components/SnippetCard.astro
+5 −0
@@ -19,10 +19,13 @@ interface Props { | ||
| 19 | 19 | tags: string[]; |
| 20 | 20 | techniques: string[]; |
| 21 | 21 | code: string; |
| 22 | + created: string; | |
| 22 | 23 | }; |
| 23 | 24 | } |
| 24 | 25 | |
| 25 | 26 | const { snippet } = Astro.props; |
| 27 | +// "New" badge for snippets created in the last 14 days (evaluated at build time) | |
| 28 | +const isNew = Date.now() - Date.parse(snippet.created) < 14 * 24 * 60 * 60 * 1000; | |
| 26 | 29 | --- |
| 27 | 30 | |
| 28 | 31 | <article |
@@ -30,6 +33,7 @@ const { snippet } = Astro.props; | ||
| 30 | 33 | data-slug={snippet.slug} |
| 31 | 34 | data-category={snippet.category} |
| 32 | 35 | data-difficulty={snippet.difficulty} |
| 36 | + data-techniques={snippet.techniques.join('|')} | |
| 33 | 37 | > |
| 34 | 38 | <LivePreview snippet={snippet} variant="card" /> |
| 35 | 39 | <div class="sg-card-body"> |
@@ -37,6 +41,7 @@ const { snippet } = Astro.props; | ||
| 37 | 41 | <a href={`/snippet/${snippet.slug}`}>{snippet.title}</a> |
| 38 | 42 | </h2> |
| 39 | 43 | <div class="sg-card-meta"> |
| 44 | + {isNew && <span class="sg-badge sg-badge-new">New</span>} | |
| 40 | 45 | <span class="sg-badge sg-badge-cat">{snippet.category}</span> |
| 41 | 46 | <span class={`sg-badge sg-badge-${snippet.difficulty}`}>{snippet.difficulty}</span> |
| 42 | 47 | {snippet.tags.slice(0, 3).map((t) => <span class="sg-badge">{t}</span>)} |
modified
src/components/TagFilter.astro
+27 −2
@@ -10,9 +10,10 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | interface Props { |
| 12 | 12 | categories: { name: string; count: number }[]; |
| 13 | + techniques?: { name: string; count: number }[]; | |
| 13 | 14 | } |
| 14 | 15 | |
| 15 | −const { categories } = Astro.props; | |
| 16 | +const { categories, techniques = [] } = Astro.props; | |
| 16 | 17 | const difficulties = ['beginner', 'intermediate', 'advanced']; |
| 17 | 18 | --- |
| 18 | 19 | |
@@ -36,11 +37,35 @@ const difficulties = ['beginner', 'intermediate', 'advanced']; | ||
| 36 | 37 | )) |
| 37 | 38 | } |
| 38 | 39 | </div> |
| 40 | +{ | |
| 41 | + techniques.length > 0 && ( | |
| 42 | + <details class="sg-technique-facet"> | |
| 43 | + <summary class="sg-chip">technique ▾</summary> | |
| 44 | + <div class="sg-chips" role="group" aria-label="Filter by technique" data-sg-filter="technique"> | |
| 45 | + <button class="sg-chip" type="button" data-value="" aria-pressed="true"> | |
| 46 | + any technique | |
| 47 | + </button> | |
| 48 | + {techniques.map((t) => ( | |
| 49 | + <button class="sg-chip" type="button" data-value={t.name} aria-pressed="false"> | |
| 50 | + {t.name} · {t.count} | |
| 51 | + </button> | |
| 52 | + ))} | |
| 53 | + </div> | |
| 54 | + </details> | |
| 55 | + ) | |
| 56 | +} | |
| 57 | + | |
| 58 | +<style> | |
| 59 | + .sg-technique-facet { flex-basis: 100%; } | |
| 60 | + .sg-technique-facet summary { list-style: none; display: inline-block; cursor: pointer; user-select: none; } | |
| 61 | + .sg-technique-facet summary::-webkit-details-marker { display: none; } | |
| 62 | + .sg-technique-facet[open] summary { margin-bottom: 0.5rem; } | |
| 63 | +</style> | |
| 39 | 64 | |
| 40 | 65 | <script> |
| 41 | 66 | // Single-select chip groups. Emits sg:filter; the gallery script combines |
| 42 | 67 | // this with search results to decide which cards stay visible. |
| 43 | − const state: Record<string, string> = { category: '', difficulty: '' }; | |
| 68 | + const state: Record<string, string> = { category: '', difficulty: '', technique: '' }; | |
| 44 | 69 | |
| 45 | 70 | for (const group of document.querySelectorAll<HTMLElement>('[data-sg-filter]')) { |
| 46 | 71 | const key = group.dataset.sgFilter!; |
modified
src/lib/snippets.mjs
+7 −0
@@ -21,7 +21,13 @@ export const CATEGORIES = [ | ||
| 21 | 21 | 'hover', |
| 22 | 22 | 'gauges', |
| 23 | 23 | 'text', |
| 24 | + 'text-fx', | |
| 24 | 25 | 'morph', |
| 26 | + 'motion-path', | |
| 27 | + 'filters', | |
| 28 | + 'scroll', | |
| 29 | + 'charts', | |
| 30 | + 'interactive', | |
| 25 | 31 | 'backgrounds', |
| 26 | 32 | 'buttons', |
| 27 | 33 | ]; |
@@ -95,6 +101,7 @@ export function parseSnippetSource(source, relPath) { | ||
| 95 | 101 | howItWorks: String(meta.how_it_works).trim(), |
| 96 | 102 | customizable, |
| 97 | 103 | created: String(meta.created), |
| 104 | + support: meta.support ? String(meta.support).trim() : null, | |
| 98 | 105 | desc, |
| 99 | 106 | relPath, |
| 100 | 107 | code, |
modified
src/pages/index.astro
+15 −3
@@ -16,6 +16,16 @@ import { loadSnippets, loadCategories } from '../lib/snippets.mjs'; | ||
| 16 | 16 | |
| 17 | 17 | const snippets = await loadSnippets(); |
| 18 | 18 | const categories = await loadCategories(); |
| 19 | + | |
| 20 | +// Technique facet: most-used techniques across the bank (top 18) | |
| 21 | +const techniqueCounts = new Map(); | |
| 22 | +for (const s of snippets) { | |
| 23 | + for (const t of s.techniques) techniqueCounts.set(t, (techniqueCounts.get(t) ?? 0) + 1); | |
| 24 | +} | |
| 25 | +const techniques = [...techniqueCounts.entries()] | |
| 26 | + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) | |
| 27 | + .slice(0, 18) | |
| 28 | + .map(([name, count]) => ({ name, count })); | |
| 19 | 29 | --- |
| 20 | 30 | |
| 21 | 31 | <Base> |
@@ -33,7 +43,7 @@ const categories = await loadCategories(); | ||
| 33 | 43 | <span class="sg-count" data-sg-count aria-live="polite">{snippets.length} snippets</span> |
| 34 | 44 | </div> |
| 35 | 45 | <div class="sg-toolbar" style="padding-top: 0;"> |
| 36 | − <TagFilter categories={categories} /> | |
| 46 | + <TagFilter categories={categories} techniques={techniques} /> | |
| 37 | 47 | </div> |
| 38 | 48 | |
| 39 | 49 | <div class="sg-grid" data-sg-grid> |
@@ -51,6 +61,7 @@ const categories = await loadCategories(); | ||
| 51 | 61 | let matched: string[] | null = null; // null → no active search |
| 52 | 62 | let category = ''; |
| 53 | 63 | let difficulty = ''; |
| 64 | + let technique = ''; | |
| 54 | 65 | |
| 55 | 66 | const apply = () => { |
| 56 | 67 | const order = new Map(matched?.map((slug, i) => [slug, i])); |
@@ -59,7 +70,8 @@ const categories = await loadCategories(); | ||
| 59 | 70 | const okSearch = matched === null || order.has(card.dataset.slug!); |
| 60 | 71 | const okCat = !category || card.dataset.category === category; |
| 61 | 72 | const okDiff = !difficulty || card.dataset.difficulty === difficulty; |
| 62 | − const show = okSearch && okCat && okDiff; | |
| 73 | + const okTech = !technique || (card.dataset.techniques ?? '').split('|').includes(technique); | |
| 74 | + const show = okSearch && okCat && okDiff && okTech; | |
| 63 | 75 | card.hidden = !show; |
| 64 | 76 | card.style.order = String(order.get(card.dataset.slug!) ?? 0); |
| 65 | 77 | if (show) visible++; |
@@ -73,7 +85,7 @@ const categories = await loadCategories(); | ||
| 73 | 85 | apply(); |
| 74 | 86 | }); |
| 75 | 87 | document.addEventListener('sg:filter', (e) => { |
| 76 | − ({ category, difficulty } = (e as CustomEvent).detail); | |
| 88 | + ({ category, difficulty, technique } = (e as CustomEvent).detail); | |
| 77 | 89 | apply(); |
| 78 | 90 | }); |
| 79 | 91 | </script> |
modified
src/pages/snippet/[slug].astro
+8 −0
@@ -55,6 +55,14 @@ const island = { | ||
| 55 | 55 | <div class="sg-detail-grid"> |
| 56 | 56 | <div> |
| 57 | 57 | <LivePreview snippet={snippet} variant="full" /> |
| 58 | + { | |
| 59 | + snippet.support && ( | |
| 60 | + <p class="sg-support-note"> | |
| 61 | + <strong>⚠ Browser support</strong> | |
| 62 | + <span>{snippet.support}</span> | |
| 63 | + </p> | |
| 64 | + ) | |
| 65 | + } | |
| 58 | 66 | <aside class="sg-how"> |
| 59 | 67 | <h2>How it works</h2> |
| 60 | 68 | <p>{snippet.howItWorks}</p> |
modified
src/styles/global.css
+15 −0
@@ -273,6 +273,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, [tabindex]:focus-vis | ||
| 273 | 273 | white-space: nowrap; |
| 274 | 274 | } |
| 275 | 275 | .sg-badge-cat { border-color: var(--sg-accent); color: var(--sg-accent-ink); background: var(--sg-accent-soft); } |
| 276 | +.sg-badge-new { background: var(--sg-accent-ink); border-color: var(--sg-accent-ink); color: var(--sg-bg); font-weight: 600; } | |
| 276 | 277 | .sg-badge-beginner { color: var(--sg-ok); border-color: currentColor; } |
| 277 | 278 | .sg-badge-intermediate { color: var(--sg-accent-ink); border-color: currentColor; } |
| 278 | 279 | .sg-badge-advanced { color: var(--sg-err); border-color: currentColor; } |
@@ -401,6 +402,20 @@ a:focus-visible, button:focus-visible, input:focus-visible, [tabindex]:focus-vis | ||
| 401 | 402 | .sg-how h2 { font-size: 1.05rem; margin-bottom: 0.35rem; } |
| 402 | 403 | .sg-how p { margin: 0; color: var(--sg-text); } |
| 403 | 404 | |
| 405 | +.sg-support-note { | |
| 406 | + display: flex; | |
| 407 | + gap: 0.5rem; | |
| 408 | + align-items: baseline; | |
| 409 | + border: 1px solid var(--sg-border); | |
| 410 | + border-radius: var(--sg-radius); | |
| 411 | + background: var(--sg-bg-soft); | |
| 412 | + padding: 0.65rem 0.9rem; | |
| 413 | + font-size: 0.85rem; | |
| 414 | + color: var(--sg-text-soft); | |
| 415 | + margin: 1rem 0 0; | |
| 416 | +} | |
| 417 | +.sg-support-note strong { color: var(--sg-text); font-weight: 600; white-space: nowrap; } | |
| 418 | + | |
| 404 | 419 | .sg-section { padding: 1.5rem 0 0; } |
| 405 | 420 | .sg-related { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; } |
| 406 | 421 | |
| 407 | 422 | |