SPB Git

spb/svgarden Public

SVGarden — searchable bank of 74 self-contained SVG+CSS animation snippets (svgarden.dev)

HTML 79.2% Astro 10.3% JavaScript 6% CSS 3.7% Shell 0.8%

feat(site): phase-2 — New badge, technique facet, support notes, 150-line limit, new categories

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 4 h ago (Aug 10, 2026) parent 627e60f

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