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): gallery, detail pages, customizer, search, filters, dark mode

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

Showing 16 changed files with +1,374 and −0

added public/favicon.svg +15 −0
@@ -0,0 +1,15 @@
1 +<!--
2 + ============================================================
3 + SVGarden — https://www.svgarden.dev
4 + Author : Simon-Pierre Boucher
5 + Contact: contact@spboucher.ai
6 + File : public/favicon.svg
7 + Desc : SVGarden favicon — a violet sprout
8 + ============================================================
9 +-->
10 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
11 + <rect width="32" height="32" rx="7" fill="#7F77DD"/>
12 + <path d="M16 26 V13" stroke="#ffffff" stroke-width="2.4" stroke-linecap="round" fill="none"/>
13 + <path d="M16 17 C 16 11.5, 10.5 11.5, 7.5 7 C 14 7, 17 10.5, 16 17 Z" fill="#ffffff"/>
14 + <path d="M16 21 C 16 16.5, 20.5 16.5, 24.5 12 C 18 12, 15 15.5, 16 21 Z" fill="#ffffff" opacity="0.7"/>
15 +</svg>
added src/components/CodeBlock.astro +80 −0
@@ -0,0 +1,80 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/components/CodeBlock.astro
8 + * Desc : Shiki-highlighted code panel with Copy + Download buttons
9 + * ============================================================
10 + */
11 +import { Code } from 'astro:components';
12 +
13 +interface Props {
14 + code: string; // displayed AND copied — includes the attribution header
15 + label: string; // e.g. snippets/loaders/spinner-dash.html
16 + downloadName?: string; // e.g. spinner-dash.html — omits the Download button if absent
17 +}
18 +
19 +const { code, label, downloadName } = Astro.props;
20 +---
21 +
22 +<div class="sg-panel sg-codeblock" data-sg-codeblock>
23 + <div class="sg-panel-bar">
24 + <strong>Code</strong>
25 + <span>{label}</span>
26 + <span class="sg-spacer"></span>
27 + {downloadName && (
28 + <button class="sg-btn sg-btn-sm" type="button" data-sg-download={downloadName}>
29 + Download .html
30 + </button>
31 + )}
32 + <button class="sg-btn sg-btn-sm sg-btn-primary" type="button" data-sg-copy>
33 + Copy code
34 + </button>
35 + </div>
36 + <Code code={code} lang="html" themes={{ light: 'github-light', dark: 'github-dark' }} />
37 + <script type="text/plain" data-sg-copy-source>{code}</script>
38 +</div>
39 +
40 +<style is:global>
41 + /* Dual-theme Shiki: swap to the dark palette when the site is dark */
42 + .astro-code { background: var(--sg-bg-inset) !important; }
43 + [data-theme='dark'] .astro-code,
44 + [data-theme='dark'] .astro-code span {
45 + color: var(--shiki-dark) !important;
46 + background-color: transparent !important;
47 + font-style: var(--shiki-dark-font-style) !important;
48 + font-weight: var(--shiki-dark-font-weight) !important;
49 + }
50 + [data-theme='dark'] .astro-code { background-color: #16161a !important; }
51 +</style>
52 +
53 +<script>
54 + // Copy + download. The current text lives in the data-sg-copy-source island;
55 + // the Customizer script rewrites it when values change.
56 + for (const block of document.querySelectorAll('[data-sg-codeblock]')) {
57 + const source = () => block.querySelector('[data-sg-copy-source]')?.textContent ?? '';
58 +
59 + const copyBtn = block.querySelector<HTMLButtonElement>('[data-sg-copy]');
60 + copyBtn?.addEventListener('click', async () => {
61 + await navigator.clipboard.writeText(source());
62 + copyBtn.classList.add('sg-btn-done');
63 + copyBtn.textContent = 'Copied ✓';
64 + setTimeout(() => {
65 + copyBtn.classList.remove('sg-btn-done');
66 + copyBtn.textContent = 'Copy code';
67 + }, 1600);
68 + });
69 +
70 + const dlBtn = block.querySelector<HTMLButtonElement>('[data-sg-download]');
71 + dlBtn?.addEventListener('click', () => {
72 + const blob = new Blob([source()], { type: 'text/html' });
73 + const a = document.createElement('a');
74 + a.href = URL.createObjectURL(blob);
75 + a.download = dlBtn.dataset.sgDownload!;
76 + a.click();
77 + URL.revokeObjectURL(a.href);
78 + });
79 + }
80 +</script>
added src/components/Customizer.astro +67 −0
@@ -0,0 +1,67 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/components/Customizer.astro
8 + * Desc : Auto-generated controls from frontmatter — live preview + code rewrite
9 + * ============================================================
10 + */
11 +interface Control {
12 + var: string;
13 + label: string;
14 + type: 'color' | 'range';
15 + default: string | number;
16 + min?: number;
17 + max?: number;
18 + step?: number;
19 + unit?: string;
20 +}
21 +
22 +interface Props {
23 + controls: Control[];
24 +}
25 +
26 +const { controls } = Astro.props;
27 +const cssValue = (c: Control) => `${c.default}${c.unit ?? ''}`;
28 +---
29 +
30 +<div class="sg-panel">
31 + <div class="sg-panel-bar"><strong>Customize</strong><span>updates preview, code &amp; clipboard</span></div>
32 + <form class="sg-customizer" data-sg-customizer>
33 + {
34 + controls.map((c, i) => (
35 + <div class="sg-control">
36 + <label for={`sg-ctl-${i}`}>{c.label}</label>
37 + <output for={`sg-ctl-${i}`}>{cssValue(c)}</output>
38 + {c.type === 'color' ? (
39 + <input
40 + type="color"
41 + id={`sg-ctl-${i}`}
42 + value={String(c.default)}
43 + data-var={c.var}
44 + data-default={String(c.default)}
45 + data-unit=""
46 + />
47 + ) : (
48 + <input
49 + type="range"
50 + id={`sg-ctl-${i}`}
51 + min={c.min}
52 + max={c.max}
53 + step={c.step ?? 1}
54 + value={String(c.default)}
55 + data-var={c.var}
56 + data-default={String(c.default)}
57 + data-unit={c.unit ?? ''}
58 + />
59 + )}
60 + </div>
61 + ))
62 + }
63 + <div class="sg-customizer-actions">
64 + <button class="sg-btn sg-btn-sm" type="reset" data-sg-reset>Reset defaults</button>
65 + </div>
66 + </form>
67 +</div>
added src/components/LivePreview.astro +49 −0
@@ -0,0 +1,49 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/components/LivePreview.astro
8 + * Desc : Sandboxed iframe preview — lazy for cards, eager + bg toggle for detail
9 + * ============================================================
10 + */
11 +import { previewDocument } from '../lib/snippets.mjs';
12 +
13 +interface Props {
14 + snippet: { slug: string; title: string; code: string };
15 + variant?: 'card' | 'full';
16 +}
17 +
18 +const { snippet, variant = 'card' } = Astro.props;
19 +const doc = previewDocument(snippet, { padding: variant === 'card' ? '0.75rem' : '2rem' });
20 +---
21 +
22 +{
23 + variant === 'card' ? (
24 + <div class="sg-card-preview">
25 + <iframe
26 + title={`Live preview: ${snippet.title}`}
27 + data-sg-lazy-srcdoc={doc}
28 + sandbox="allow-scripts allow-same-origin"
29 + loading="lazy"
30 + tabindex="-1"
31 + style="pointer-events: none;"
32 + />
33 + </div>
34 + ) : (
35 + <div class="sg-panel">
36 + <div class="sg-panel-bar">
37 + <strong>Live preview</strong>
38 + <span class="sg-spacer" />
39 + <div class="sg-bg-toggle" role="group" aria-label="Preview background">
40 + <button type="button" data-sg-bg="#ffffff" aria-pressed="true">Light</button>
41 + <button type="button" data-sg-bg="#111113" aria-pressed="false">Dark</button>
42 + </div>
43 + </div>
44 + <div class="sg-preview-stage">
45 + <iframe id="sg-preview-frame" title={`Live preview: ${snippet.title}`} srcdoc={doc} sandbox="allow-scripts allow-same-origin" />
46 + </div>
47 + </div>
48 + )
49 +}
added src/components/SearchBar.astro +72 −0
@@ -0,0 +1,72 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/components/SearchBar.astro
8 + * Desc : Instant client-side search — Fuse.js lazy-loaded on first focus
9 + * ============================================================
10 + */
11 +---
12 +
13 +<div class="sg-search">
14 + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
15 + <circle cx="11" cy="11" r="7" />
16 + <path d="m20 20-3.5-3.5" />
17 + </svg>
18 + <input
19 + type="search"
20 + id="sg-search-input"
21 + placeholder="Search animations… (title, tag, technique)"
22 + autocomplete="off"
23 + aria-label="Search animations"
24 + />
25 + <kbd aria-hidden="true">/</kbd>
26 +</div>
27 +
28 +<script>
29 + import type Fuse from 'fuse.js';
30 +
31 + const input = document.getElementById('sg-search-input') as HTMLInputElement;
32 + let fuse: Fuse<{ slug: string }> | null = null;
33 + let loading: Promise<void> | null = null;
34 +
35 + // Lazy-load Fuse.js + the index only when the visitor actually searches.
36 + const ensureIndex = () =>
37 + (loading ??= Promise.all([
38 + import('fuse.js'),
39 + fetch('/search-index.json').then((r) => r.json()),
40 + ]).then(([{ default: FuseCtor }, docs]) => {
41 + fuse = new FuseCtor(docs, {
42 + keys: [
43 + { name: 'title', weight: 3 },
44 + { name: 'tags', weight: 2 },
45 + { name: 'techniques', weight: 2 },
46 + { name: 'category', weight: 1 },
47 + { name: 'desc', weight: 1 },
48 + ],
49 + threshold: 0.35,
50 + ignoreLocation: true,
51 + });
52 + }));
53 +
54 + const emit = (slugs: string[] | null) =>
55 + document.dispatchEvent(new CustomEvent('sg:search', { detail: { slugs } }));
56 +
57 + input.addEventListener('focus', () => void ensureIndex(), { once: true });
58 + input.addEventListener('input', async () => {
59 + const q = input.value.trim();
60 + if (!q) return emit(null);
61 + await ensureIndex();
62 + emit(fuse!.search(q).map((r) => r.item.slug));
63 + });
64 +
65 + // `/` focuses search from anywhere (unless already typing in a field)
66 + document.addEventListener('keydown', (e) => {
67 + if (e.key === '/' && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
68 + e.preventDefault();
69 + input.focus();
70 + }
71 + });
72 +</script>
added src/components/SnippetCard.astro +75 −0
@@ -0,0 +1,75 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/components/SnippetCard.astro
8 + * Desc : Gallery card — lazy live preview, title, category + difficulty badges
9 + * ============================================================
10 + */
11 +import LivePreview from './LivePreview.astro';
12 +
13 +interface Props {
14 + snippet: {
15 + slug: string;
16 + title: string;
17 + category: string;
18 + difficulty: string;
19 + tags: string[];
20 + techniques: string[];
21 + code: string;
22 + };
23 +}
24 +
25 +const { snippet } = Astro.props;
26 +---
27 +
28 +<article
29 + class="sg-card"
30 + data-slug={snippet.slug}
31 + data-category={snippet.category}
32 + data-difficulty={snippet.difficulty}
33 +>
34 + <LivePreview snippet={snippet} variant="card" />
35 + <div class="sg-card-body">
36 + <h3 class="sg-card-title">
37 + <a href={`/snippet/${snippet.slug}`}>{snippet.title}</a>
38 + </h3>
39 + <div class="sg-card-meta">
40 + <span class="sg-badge sg-badge-cat">{snippet.category}</span>
41 + <span class={`sg-badge sg-badge-${snippet.difficulty}`}>{snippet.difficulty}</span>
42 + {snippet.tags.slice(0, 3).map((t) => <span class="sg-badge">{t}</span>)}
43 + </div>
44 + </div>
45 +</article>
46 +
47 +<script>
48 + // Lazy-render previews when cards approach the viewport, and keep the
49 + // preview background in sync with the site theme. Runs once per page.
50 + const setBg = (frame: HTMLIFrameElement) => {
51 + const dark = document.documentElement.dataset.theme === 'dark';
52 + frame.contentDocument?.body?.style.setProperty('--sg-preview-bg', dark ? '#111113' : '#ffffff');
53 + };
54 +
55 + const frames = [...document.querySelectorAll<HTMLIFrameElement>('iframe[data-sg-lazy-srcdoc]')];
56 +
57 + const io = new IntersectionObserver(
58 + (entries) => {
59 + for (const entry of entries) {
60 + if (!entry.isIntersecting) continue;
61 + const frame = entry.target as HTMLIFrameElement;
62 + frame.addEventListener('load', () => setBg(frame), { once: true });
63 + frame.srcdoc = frame.dataset.sgLazySrcdoc!;
64 + frame.removeAttribute('data-sg-lazy-srcdoc');
65 + io.unobserve(frame);
66 + }
67 + },
68 + { rootMargin: '300px' }
69 + );
70 + frames.forEach((f) => io.observe(f));
71 +
72 + new MutationObserver(() => {
73 + document.querySelectorAll<HTMLIFrameElement>('.sg-card-preview iframe').forEach(setBg);
74 + }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
75 +</script>
added src/components/TagFilter.astro +57 −0
@@ -0,0 +1,57 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/components/TagFilter.astro
8 + * Desc : Category + difficulty filter chips for the gallery
9 + * ============================================================
10 + */
11 +interface Props {
12 + categories: { name: string; count: number }[];
13 +}
14 +
15 +const { categories } = Astro.props;
16 +const difficulties = ['beginner', 'intermediate', 'advanced'];
17 +---
18 +
19 +<div class="sg-chips" role="group" aria-label="Filter by category" data-sg-filter="category">
20 + <button class="sg-chip" type="button" data-value="" aria-pressed="true">All</button>
21 + {
22 + categories.map((c) => (
23 + <button class="sg-chip" type="button" data-value={c.name} aria-pressed="false">
24 + {c.name} · {c.count}
25 + </button>
26 + ))
27 + }
28 +</div>
29 +<div class="sg-chips" role="group" aria-label="Filter by difficulty" data-sg-filter="difficulty">
30 + <button class="sg-chip" type="button" data-value="" aria-pressed="true">any level</button>
31 + {
32 + difficulties.map((d) => (
33 + <button class="sg-chip" type="button" data-value={d} aria-pressed="false">
34 + {d}
35 + </button>
36 + ))
37 + }
38 +</div>
39 +
40 +<script>
41 + // Single-select chip groups. Emits sg:filter; the gallery script combines
42 + // this with search results to decide which cards stay visible.
43 + const state: Record<string, string> = { category: '', difficulty: '' };
44 +
45 + for (const group of document.querySelectorAll<HTMLElement>('[data-sg-filter]')) {
46 + const key = group.dataset.sgFilter!;
47 + group.addEventListener('click', (e) => {
48 + const chip = (e.target as HTMLElement).closest<HTMLButtonElement>('.sg-chip');
49 + if (!chip) return;
50 + state[key] = chip.dataset.value ?? '';
51 + for (const c of group.querySelectorAll('.sg-chip')) {
52 + c.setAttribute('aria-pressed', String(c === chip));
53 + }
54 + document.dispatchEvent(new CustomEvent('sg:filter', { detail: { ...state } }));
55 + });
56 + }
57 +</script>
added src/layouts/Base.astro +95 −0
@@ -0,0 +1,95 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/layouts/Base.astro
8 + * Desc : Base layout — head, theme init, header nav, footer
9 + * ============================================================
10 + */
11 +import '../styles/global.css';
12 +
13 +interface Props {
14 + title?: string;
15 + description?: string;
16 +}
17 +
18 +const {
19 + title = 'SVGarden — SVG + CSS animation snippets',
20 + description = 'A searchable garden of self-contained SVG + CSS animations. Preview, customize, copy — every snippet works in a blank HTML file.',
21 +} = Astro.props;
22 +
23 +const canonical = new URL(Astro.url.pathname, Astro.site);
24 +const current = Astro.url.pathname;
25 +---
26 +
27 +<!DOCTYPE html>
28 +<html lang="en">
29 + <head>
30 + <meta charset="utf-8" />
31 + <meta name="viewport" content="width=device-width, initial-scale=1" />
32 + <title>{title}</title>
33 + <meta name="description" content={description} />
34 + <meta name="author" content="Simon-Pierre Boucher" />
35 + <link rel="canonical" href={canonical} />
36 + <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
37 + <meta property="og:title" content={title} />
38 + <meta property="og:description" content={description} />
39 + <meta property="og:type" content="website" />
40 + <meta property="og:url" content={canonical} />
41 + <meta property="og:site_name" content="SVGarden" />
42 + <script is:inline>
43 + // Theme init — runs before paint to avoid a flash of the wrong theme.
44 + (() => {
45 + const stored = localStorage.getItem('sg-theme');
46 + const theme = stored ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
47 + document.documentElement.dataset.theme = theme;
48 + })();
49 + </script>
50 + </head>
51 + <body>
52 + <a class="sg-skip-link" href="#sg-main">Skip to content</a>
53 + <header class="sg-header">
54 + <div class="sg-container sg-header-inner">
55 + <a class="sg-logo" href="/" aria-label="SVGarden home">
56 + <svg width="26" height="26" viewBox="0 0 32 32" aria-hidden="true">
57 + <path d="M16 28 V14" stroke="var(--sg-accent)" stroke-width="2.5" stroke-linecap="round" fill="none" />
58 + <path d="M16 18 C 16 12, 10 12, 7 7 C 14 7, 17 11, 16 18 Z" fill="var(--sg-accent)" />
59 + <path d="M16 22 C 16 17, 21 17, 25 12 C 18 12, 15 16, 16 22 Z" fill="var(--sg-accent)" opacity="0.55" />
60 + </svg>
61 + SVGarden
62 + </a>
63 + <nav class="sg-nav" aria-label="Main">
64 + <a href="/" aria-current={current === '/' ? 'page' : undefined}>Gallery</a>
65 + <a href="/about" aria-current={current === '/about' ? 'page' : undefined}>About</a>
66 + <button class="sg-theme-toggle" type="button" aria-label="Toggle dark mode" data-sg-theme-toggle>
67 + <svg class="sg-icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
68 + <path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z" />
69 + </svg>
70 + <svg class="sg-icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
71 + <circle cx="12" cy="12" r="4" />
72 + <path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
73 + </svg>
74 + </button>
75 + </nav>
76 + </div>
77 + </header>
78 + <main id="sg-main" class="sg-container">
79 + <slot />
80 + </main>
81 + <footer class="sg-footer">
82 + <div class="sg-container">
83 + © Simon-Pierre Boucher — <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a> — <a href="https://www.svgarden.dev">svgarden.dev</a>
84 + </div>
85 + </footer>
86 + <script>
87 + const toggle = document.querySelector('[data-sg-theme-toggle]');
88 + toggle?.addEventListener('click', () => {
89 + const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
90 + document.documentElement.dataset.theme = next;
91 + localStorage.setItem('sg-theme', next);
92 + });
93 + </script>
94 + </body>
95 +</html>
added src/pages/404.astro +37 −0
@@ -0,0 +1,37 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/pages/404.astro
8 + * Desc : 404 page — a wilted-flower stroke animation, of course
9 + * ============================================================
10 + */
11 +import Base from '../layouts/Base.astro';
12 +---
13 +
14 +<Base title="404 — SVGarden" description="Page not found.">
15 + <section class="sg-hero" style="text-align: center; padding: 4rem 0;">
16 + <svg width="120" height="120" viewBox="0 0 64 64" aria-hidden="true" style="margin: 0 auto; display: block;">
17 + <path
18 + d="M32 56 C 32 44, 32 36, 32 30 C 32 22, 26 18, 20 20 C 22 26, 27 28, 32 30 M 32 26 C 34 18, 42 16, 46 18 C 44 24, 38 26, 32 26"
19 + fill="none"
20 + stroke="var(--sg-accent)"
21 + stroke-width="2.5"
22 + stroke-linecap="round"
23 + stroke-dasharray="140"
24 + stroke-dashoffset="140"
25 + style="animation: sg-404-draw 2s ease forwards;"
26 + />
27 + </svg>
28 + <h1>404 — nothing planted here</h1>
29 + <p style="color: var(--sg-text-soft);">This page doesn't exist (yet). The garden is back at the gallery.</p>
30 + <p><a class="sg-btn sg-btn-primary" href="/">Back to the gallery</a></p>
31 + </section>
32 + <style>
33 + @keyframes sg-404-draw {
34 + to { stroke-dashoffset: 0; }
35 + }
36 + </style>
37 +</Base>
added src/pages/about.astro +51 −0
@@ -0,0 +1,51 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/pages/about.astro
8 + * Desc : About page — what SVGarden is and how to use the snippets
9 + * ============================================================
10 + */
11 +import Base from '../layouts/Base.astro';
12 +import { loadSnippets, loadCategories } from '../lib/snippets.mjs';
13 +
14 +const snippets = await loadSnippets();
15 +const categories = await loadCategories();
16 +---
17 +
18 +<Base title="About — SVGarden" description="What SVGarden is, how the snippets work, and how to use them in your own projects.">
19 + <article class="sg-about">
20 + <h1>About SVGarden</h1>
21 + <p>
22 + SVGarden is a growing bank of <strong>{snippets.length} SVG + CSS animations</strong> across
23 + {categories.length} categories — loaders, stroke drawing, hover effects, gauges, animated text,
24 + shape morphs and backgrounds. It is built and maintained by
25 + <a href="mailto:contact@spboucher.ai">Simon-Pierre Boucher</a>.
26 + </p>
27 +
28 + <h2>The rules every snippet follows</h2>
29 + <ul>
30 + <li><strong>Self-contained</strong> — paste any snippet into a blank <code>.html</code> file and it works. No frameworks, no CDNs, no fonts, no build step.</li>
31 + <li><strong>Scoped</strong> — every class is prefixed <code>sg-</code>, so multiple snippets coexist on one page without collisions.</li>
32 + <li><strong>Customizable</strong> — colors, sizes and speeds are CSS custom properties (<code>--sg-*</code>) on the root element. The customizer on each page rewrites them live.</li>
33 + <li><strong>Pedagogical</strong> — each snippet ships with a short “How it works” explaining the technique, not just the code.</li>
34 + <li><strong>Accessible</strong> — decorative SVGs are <code>aria-hidden</code>; meaningful ones get <code>role="img"</code> and a <code>&lt;title&gt;</code>.</li>
35 + </ul>
36 +
37 + <h2>How to use a snippet</h2>
38 + <p>
39 + Open any snippet, tweak it with the customizer, then <em>Copy code</em> (or <em>Download .html</em>).
40 + The attribution header travels with the code — you're free to use the snippets in your projects;
41 + keeping the header is appreciated.
42 + </p>
43 +
44 + <h2>Colophon</h2>
45 + <p>
46 + Built with Astro, highlighted at build time with Shiki, searched client-side with a lazy-loaded
47 + Fuse.js. The site itself is styled with the same vanilla CSS techniques the snippets teach —
48 + no CSS frameworks anywhere.
49 + </p>
50 + </article>
51 +</Base>
added src/pages/build-info.json.js +18 −0
@@ -0,0 +1,18 @@
1 +/**
2 + * ============================================================
3 + * SVGarden — https://www.svgarden.dev
4 + * Author : Simon-Pierre Boucher
5 + * Contact: contact@spboucher.ai
6 + * File : src/pages/build-info.json.js
7 + * Desc : Build-time endpoint — timestamp + snippet count for /healthz
8 + * ============================================================
9 + */
10 +import { loadSnippets } from '../lib/snippets.mjs';
11 +
12 +export async function GET() {
13 + const snippets = await loadSnippets();
14 + return new Response(
15 + JSON.stringify({ built: new Date().toISOString(), snippets: snippets.length }),
16 + { headers: { 'Content-Type': 'application/json' } }
17 + );
18 +}
added src/pages/category/[cat].astro +51 −0
@@ -0,0 +1,51 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/pages/category/[cat].astro
8 + * Desc : Per-category gallery page
9 + * ============================================================
10 + */
11 +import Base from '../../layouts/Base.astro';
12 +import SnippetCard from '../../components/SnippetCard.astro';
13 +import { loadSnippets, loadCategories } from '../../lib/snippets.mjs';
14 +
15 +export async function getStaticPaths() {
16 + const categories = await loadCategories();
17 + return categories.map((c) => ({ params: { cat: c.name } }));
18 +}
19 +
20 +const { cat } = Astro.params;
21 +const snippets = (await loadSnippets()).filter((s) => s.category === cat);
22 +const categories = await loadCategories();
23 +---
24 +
25 +<Base
26 + title={`${cat} — SVGarden`}
27 + description={`${snippets.length} self-contained ${cat} SVG + CSS animation snippets — preview, customize, copy.`}
28 +>
29 + <section class="sg-hero">
30 + <p class="sg-breadcrumb"><a href="/">Gallery</a> / {cat}</p>
31 + <h1>{cat}</h1>
32 + <p>{snippets.length} snippet{snippets.length === 1 ? '' : 's'} in this category.</p>
33 + </section>
34 +
35 + <div class="sg-toolbar">
36 + <div class="sg-chips" aria-label="Categories">
37 + <a class="sg-chip" href="/">All</a>
38 + {
39 + categories.map((c) => (
40 + <a class={`sg-chip${c.name === cat ? ' sg-chip-active' : ''}`} href={`/category/${c.name}`}>
41 + {c.name} · {c.count}
42 + </a>
43 + ))
44 + }
45 + </div>
46 + </div>
47 +
48 + <div class="sg-grid">
49 + {snippets.map((s) => <SnippetCard snippet={s} />)}
50 + </div>
51 +</Base>
added src/pages/index.astro +79 −0
@@ -0,0 +1,79 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/pages/index.astro
8 + * Desc : Gallery home — all snippets, live previews, search + filters
9 + * ============================================================
10 + */
11 +import Base from '../layouts/Base.astro';
12 +import SnippetCard from '../components/SnippetCard.astro';
13 +import SearchBar from '../components/SearchBar.astro';
14 +import TagFilter from '../components/TagFilter.astro';
15 +import { loadSnippets, loadCategories } from '../lib/snippets.mjs';
16 +
17 +const snippets = await loadSnippets();
18 +const categories = await loadCategories();
19 +---
20 +
21 +<Base>
22 + <section class="sg-hero">
23 + <h1>A garden of SVG + CSS animations</h1>
24 + <p>
25 + {snippets.length} self-contained snippets you can preview live, customize, and copy.
26 + Every one works when pasted into a blank <code>.html</code> file — no frameworks, no build step,
27 + and each comes with a short lesson on how it works.
28 + </p>
29 + </section>
30 +
31 + <div class="sg-toolbar">
32 + <SearchBar />
33 + <span class="sg-count" data-sg-count aria-live="polite">{snippets.length} snippets</span>
34 + </div>
35 + <div class="sg-toolbar" style="padding-top: 0;">
36 + <TagFilter categories={categories} />
37 + </div>
38 +
39 + <div class="sg-grid" data-sg-grid>
40 + {snippets.map((s) => <SnippetCard snippet={s} />)}
41 + </div>
42 + <p class="sg-empty" data-sg-empty hidden>No snippets match — try a broader search.</p>
43 +</Base>
44 +
45 +<script>
46 + // Combines search results (sg:search) with chip filters (sg:filter).
47 + const cards = [...document.querySelectorAll<HTMLElement>('[data-sg-grid] .sg-card')];
48 + const count = document.querySelector('[data-sg-count]')!;
49 + const empty = document.querySelector<HTMLElement>('[data-sg-empty]')!;
50 +
51 + let matched: string[] | null = null; // null → no active search
52 + let category = '';
53 + let difficulty = '';
54 +
55 + const apply = () => {
56 + const order = new Map(matched?.map((slug, i) => [slug, i]));
57 + let visible = 0;
58 + for (const card of cards) {
59 + const okSearch = matched === null || order.has(card.dataset.slug!);
60 + const okCat = !category || card.dataset.category === category;
61 + const okDiff = !difficulty || card.dataset.difficulty === difficulty;
62 + const show = okSearch && okCat && okDiff;
63 + card.hidden = !show;
64 + card.style.order = String(order.get(card.dataset.slug!) ?? 0);
65 + if (show) visible++;
66 + }
67 + count.textContent = `${visible} snippet${visible === 1 ? '' : 's'}`;
68 + empty.hidden = visible > 0;
69 + };
70 +
71 + document.addEventListener('sg:search', (e) => {
72 + matched = (e as CustomEvent).detail.slugs;
73 + apply();
74 + });
75 + document.addEventListener('sg:filter', (e) => {
76 + ({ category, difficulty } = (e as CustomEvent).detail);
77 + apply();
78 + });
79 +</script>
added src/pages/search-index.json.js +26 −0
@@ -0,0 +1,26 @@
1 +/**
2 + * ============================================================
3 + * SVGarden — https://www.svgarden.dev
4 + * Author : Simon-Pierre Boucher
5 + * Contact: contact@spboucher.ai
6 + * File : src/pages/search-index.json.js
7 + * Desc : Build-time endpoint — Fuse.js search index for the whole bank
8 + * ============================================================
9 + */
10 +import { loadSnippets } from '../lib/snippets.mjs';
11 +
12 +export async function GET() {
13 + const snippets = await loadSnippets();
14 + const index = snippets.map((s) => ({
15 + slug: s.slug,
16 + title: s.title,
17 + category: s.category,
18 + difficulty: s.difficulty,
19 + tags: s.tags,
20 + techniques: s.techniques,
21 + desc: s.desc,
22 + }));
23 + return new Response(JSON.stringify(index), {
24 + headers: { 'Content-Type': 'application/json' },
25 + });
26 +}
added src/pages/snippet/[slug].astro +187 −0
@@ -0,0 +1,187 @@
1 +---
2 +/**
3 + * ============================================================
4 + * SVGarden — https://www.svgarden.dev
5 + * Author : Simon-Pierre Boucher
6 + * Contact: contact@spboucher.ai
7 + * File : src/pages/snippet/[slug].astro
8 + * Desc : Snippet detail — live preview, customizer, lesson, copy-ready code
9 + * ============================================================
10 + */
11 +import Base from '../../layouts/Base.astro';
12 +import LivePreview from '../../components/LivePreview.astro';
13 +import CodeBlock from '../../components/CodeBlock.astro';
14 +import Customizer from '../../components/Customizer.astro';
15 +import SnippetCard from '../../components/SnippetCard.astro';
16 +import { loadSnippets, copyText } from '../../lib/snippets.mjs';
17 +
18 +export async function getStaticPaths() {
19 + const snippets = await loadSnippets();
20 + return snippets.map((s) => ({ params: { slug: s.slug }, props: { snippet: s } }));
21 +}
22 +
23 +const { snippet } = Astro.props;
24 +const all = await loadSnippets();
25 +const related = all.filter((s) => s.category === snippet.category && s.slug !== snippet.slug).slice(0, 3);
26 +const initialCopy = copyText(snippet);
27 +
28 +// Everything the client-side customizer needs to rewrite preview + code + clipboard.
29 +const island = {
30 + header: snippet.header,
31 + rawCode: snippet.code,
32 + slug: snippet.slug,
33 + vars: snippet.customizable.map((c) => ({
34 + name: c.var,
35 + unit: c.unit ?? '',
36 + initial: `${c.default}${c.unit ?? ''}`,
37 + })),
38 +};
39 +---
40 +
41 +<Base title={`${snippet.title} — SVGarden`} description={snippet.desc}>
42 + <div class="sg-detail-head">
43 + <p class="sg-breadcrumb">
44 + <a href="/">Gallery</a> / <a href={`/category/${snippet.category}`}>{snippet.category}</a> / {snippet.title}
45 + </p>
46 + <h1>{snippet.title}</h1>
47 + <p style="color: var(--sg-text-soft); margin: 0;">{snippet.desc}</p>
48 + <div class="sg-detail-meta">
49 + <span class="sg-badge sg-badge-cat">{snippet.category}</span>
50 + <span class={`sg-badge sg-badge-${snippet.difficulty}`}>{snippet.difficulty}</span>
51 + {snippet.tags.map((t) => <span class="sg-badge">{t}</span>)}
52 + </div>
53 + </div>
54 +
55 + <div class="sg-detail-grid">
56 + <div>
57 + <LivePreview snippet={snippet} variant="full" />
58 + <aside class="sg-how">
59 + <h2>How it works</h2>
60 + <p>{snippet.howItWorks}</p>
61 + </aside>
62 + {
63 + snippet.techniques.length > 0 && (
64 + <p style="color: var(--sg-text-soft); font-size: 0.875rem;">
65 + Techniques: {snippet.techniques.join(' · ')}
66 + </p>
67 + )
68 + }
69 + </div>
70 + <div>
71 + {snippet.customizable.length > 0 && <Customizer controls={snippet.customizable} />}
72 + </div>
73 + </div>
74 +
75 + <section class="sg-section">
76 + <CodeBlock code={initialCopy} label={snippet.relPath} downloadName={`${snippet.slug}.html`} />
77 + </section>
78 +
79 + {
80 + related.length > 0 && (
81 + <section class="sg-section">
82 + <h2>More {snippet.category}</h2>
83 + <div class="sg-related">
84 + {related.map((s) => (
85 + <SnippetCard snippet={s} />
86 + ))}
87 + </div>
88 + </section>
89 + )
90 + }
91 +
92 + <script type="application/json" id="sg-island" set:html={JSON.stringify(island)} />
93 +</Base>
94 +
95 +<script>
96 + // ---- Detail-page wiring: customizer ⇆ preview ⇆ code block ⇆ clipboard ----
97 + const islandEl = document.getElementById('sg-island');
98 + const frame = document.getElementById('sg-preview-frame') as HTMLIFrameElement | null;
99 +
100 + // Preview background toggle (light/dark canvas behind the animation)
101 + for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-sg-bg]')) {
102 + btn.addEventListener('click', () => {
103 + frame?.contentDocument?.body?.style.setProperty('--sg-preview-bg', btn.dataset.sgBg!);
104 + document
105 + .querySelectorAll('[data-sg-bg]')
106 + .forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
107 + });
108 + }
109 +
110 + if (islandEl) {
111 + const island = JSON.parse(islandEl.textContent!) as {
112 + header: string;
113 + rawCode: string;
114 + slug: string;
115 + vars: { name: string; unit: string; initial: string }[];
116 + };
117 +
118 + const current = new Map(island.vars.map((v) => [v.name, v.initial]));
119 + const codeEl = document.querySelector('[data-sg-codeblock] pre code');
120 + const copySource = document.querySelector('[data-sg-copy-source]');
121 +
122 + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
123 +
124 + // Rebuild the full copy/download text from the raw snippet + current values.
125 + const rebuildCopyText = () => {
126 + let code = island.rawCode;
127 + for (const [name, value] of current) {
128 + code = code.replace(new RegExp(`(${esc(name)}\\s*:\\s*)[^;"']+`, 'g'), `$1${value}`);
129 + }
130 + if (copySource) copySource.textContent = `${island.header}\n${code}\n`;
131 + };
132 +
133 + // Rewrite the highlighted code block in place, keeping Shiki's tokens.
134 + // Values are declared once, on the root element, so a text-node swap is exact.
135 + const rewriteCodeDom = (name: string, oldVal: string, newVal: string) => {
136 + if (!codeEl || oldVal === newVal) return;
137 + const walker = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT);
138 + const declRe = new RegExp(`(${esc(name)}\\s*:\\s*)${esc(oldVal)}`, 'g');
139 + let hit = false;
140 + for (let node = walker.nextNode(); node; node = walker.nextNode()) {
141 + const text = node.nodeValue ?? '';
142 + if (declRe.test(text)) {
143 + node.nodeValue = text.replace(declRe, `$1${newVal}`);
144 + declRe.lastIndex = 0;
145 + hit = true;
146 + }
147 + }
148 + if (hit) return;
149 + // Fallback if the tokenizer split "name:" and "value" apart.
150 + const walker2 = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT);
151 + for (let node = walker2.nextNode(); node; node = walker2.nextNode()) {
152 + const text = node.nodeValue ?? '';
153 + if (text.includes(oldVal)) {
154 + node.nodeValue = text.replace(oldVal, newVal);
155 + return;
156 + }
157 + }
158 + };
159 +
160 + const applyControl = (input: HTMLInputElement) => {
161 + const name = input.dataset.var!;
162 + const value = `${input.value}${input.dataset.unit ?? ''}`;
163 + const prev = current.get(name)!;
164 + current.set(name, value);
165 +
166 + const output = input.closest('.sg-control')?.querySelector('output');
167 + if (output) output.textContent = value;
168 +
169 + frame?.contentDocument?.body?.firstElementChild
170 + ?.getAttribute('style') !== null &&
171 + (frame?.contentDocument?.body?.firstElementChild as HTMLElement | null)?.style.setProperty(name, value);
172 +
173 + rewriteCodeDom(name, prev, value);
174 + rebuildCopyText();
175 + };
176 +
177 + const form = document.querySelector<HTMLFormElement>('[data-sg-customizer]');
178 + form?.addEventListener('input', (e) => {
179 + if (e.target instanceof HTMLInputElement && e.target.dataset.var) applyControl(e.target);
180 + });
181 + form?.addEventListener('reset', () => {
182 + requestAnimationFrame(() => {
183 + form.querySelectorAll<HTMLInputElement>('input[data-var]').forEach(applyControl);
184 + });
185 + });
186 + }
187 +</script>
added src/styles/global.css +415 −0
@@ -0,0 +1,415 @@
1 +/*
2 + ============================================================
3 + SVGarden — https://www.svgarden.dev
4 + Author : Simon-Pierre Boucher
5 + Contact: contact@spboucher.ai
6 + File : src/styles/global.css
7 + Desc : Site-wide design system — neutral palette, one violet accent
8 + ============================================================
9 +*/
10 +
11 +:root {
12 + --sg-accent: #7f77dd;
13 + --sg-accent-ink: #5a51c8;
14 + --sg-accent-soft: rgba(127, 119, 221, 0.12);
15 + --sg-bg: #ffffff;
16 + --sg-bg-soft: #fafafa;
17 + --sg-bg-inset: #f4f4f5;
18 + --sg-text: #18181b;
19 + --sg-text-soft: #55555e;
20 + --sg-border: #e4e4e7;
21 + --sg-ok: #1a7f4e;
22 + --sg-err: #c03434;
23 + --sg-radius: 12px;
24 + --sg-maxw: 1200px;
25 + --sg-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
26 + --sg-mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
27 + color-scheme: light;
28 +}
29 +
30 +:root[data-theme='dark'] {
31 + --sg-accent-ink: #9c95e8;
32 + --sg-accent-soft: rgba(127, 119, 221, 0.18);
33 + --sg-bg: #111113;
34 + --sg-bg-soft: #18181b;
35 + --sg-bg-inset: #202024;
36 + --sg-text: #ececf0;
37 + --sg-text-soft: #a3a3af;
38 + --sg-border: #2a2a30;
39 + --sg-ok: #3dbf82;
40 + --sg-err: #e06c6c;
41 + color-scheme: dark;
42 +}
43 +
44 +* { box-sizing: border-box; }
45 +
46 +html {
47 + font-family: var(--sg-font);
48 + scroll-behavior: smooth;
49 +}
50 +
51 +body {
52 + margin: 0;
53 + background: var(--sg-bg);
54 + color: var(--sg-text);
55 + font-weight: 400;
56 + line-height: 1.6;
57 + -webkit-font-smoothing: antialiased;
58 +}
59 +
60 +h1, h2, h3, h4 { font-weight: 600; line-height: 1.25; margin: 0 0 0.5em; }
61 +h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -0.02em; }
62 +h2 { font-size: 1.35rem; }
63 +h3 { font-size: 1.05rem; }
64 +p { margin: 0 0 1em; }
65 +
66 +a { color: var(--sg-accent-ink); text-decoration: none; }
67 +a:hover { text-decoration: underline; }
68 +a:focus-visible, button:focus-visible, input:focus-visible, [tabindex]:focus-visible {
69 + outline: 2px solid var(--sg-accent);
70 + outline-offset: 2px;
71 + border-radius: 4px;
72 +}
73 +
74 +.sg-skip-link {
75 + position: absolute;
76 + left: -9999px;
77 + top: 0;
78 + background: var(--sg-accent);
79 + color: #fff;
80 + padding: 0.5rem 1rem;
81 + border-radius: 0 0 8px 0;
82 + z-index: 100;
83 +}
84 +.sg-skip-link:focus { left: 0; }
85 +
86 +.sg-container {
87 + max-width: var(--sg-maxw);
88 + margin: 0 auto;
89 + padding: 0 clamp(1rem, 3vw, 2rem);
90 +}
91 +
92 +/* ---------- header / footer ---------- */
93 +.sg-header {
94 + border-bottom: 1px solid var(--sg-border);
95 + background: color-mix(in srgb, var(--sg-bg) 88%, transparent);
96 + backdrop-filter: blur(8px);
97 + position: sticky;
98 + top: 0;
99 + z-index: 50;
100 +}
101 +.sg-header-inner {
102 + display: flex;
103 + align-items: center;
104 + gap: 1.25rem;
105 + min-height: 60px;
106 +}
107 +.sg-logo {
108 + display: flex;
109 + align-items: center;
110 + gap: 0.55rem;
111 + font-weight: 600;
112 + font-size: 1.1rem;
113 + color: var(--sg-text);
114 + letter-spacing: -0.01em;
115 +}
116 +.sg-logo:hover { text-decoration: none; }
117 +.sg-logo svg { display: block; }
118 +.sg-nav { display: flex; gap: 1rem; margin-left: auto; align-items: center; }
119 +.sg-nav a { color: var(--sg-text-soft); font-size: 0.925rem; }
120 +.sg-nav a:hover, .sg-nav a[aria-current='page'] { color: var(--sg-text); text-decoration: none; }
121 +
122 +.sg-theme-toggle {
123 + appearance: none;
124 + border: 1px solid var(--sg-border);
125 + background: var(--sg-bg-soft);
126 + color: var(--sg-text-soft);
127 + width: 34px;
128 + height: 34px;
129 + border-radius: 50%;
130 + cursor: pointer;
131 + display: grid;
132 + place-items: center;
133 + padding: 0;
134 +}
135 +.sg-theme-toggle:hover { color: var(--sg-text); border-color: var(--sg-text-soft); }
136 +.sg-theme-toggle .sg-icon-sun { display: none; }
137 +[data-theme='dark'] .sg-theme-toggle .sg-icon-sun { display: block; }
138 +[data-theme='dark'] .sg-theme-toggle .sg-icon-moon { display: none; }
139 +
140 +.sg-footer {
141 + border-top: 1px solid var(--sg-border);
142 + margin-top: 4rem;
143 + padding: 1.75rem 0 2.25rem;
144 + color: var(--sg-text-soft);
145 + font-size: 0.875rem;
146 +}
147 +.sg-footer a { color: inherit; text-decoration: underline; }
148 +
149 +/* ---------- hero ---------- */
150 +.sg-hero { padding: 3rem 0 1.5rem; }
151 +.sg-hero p { max-width: 56ch; color: var(--sg-text-soft); font-size: 1.05rem; }
152 +
153 +/* ---------- filter bar ---------- */
154 +.sg-toolbar {
155 + display: flex;
156 + flex-wrap: wrap;
157 + gap: 0.75rem;
158 + align-items: center;
159 + padding: 1rem 0 1.5rem;
160 +}
161 +.sg-search {
162 + flex: 1 1 260px;
163 + position: relative;
164 + max-width: 420px;
165 +}
166 +.sg-search input {
167 + width: 100%;
168 + padding: 0.55rem 2.4rem 0.55rem 2.3rem;
169 + border: 1px solid var(--sg-border);
170 + border-radius: 999px;
171 + background: var(--sg-bg-soft);
172 + color: var(--sg-text);
173 + font: inherit;
174 + font-size: 0.925rem;
175 +}
176 +.sg-search input::placeholder { color: var(--sg-text-soft); }
177 +.sg-search svg {
178 + position: absolute;
179 + left: 0.8rem;
180 + top: 50%;
181 + translate: 0 -50%;
182 + color: var(--sg-text-soft);
183 + pointer-events: none;
184 +}
185 +.sg-search kbd {
186 + position: absolute;
187 + right: 0.7rem;
188 + top: 50%;
189 + translate: 0 -50%;
190 + border: 1px solid var(--sg-border);
191 + border-radius: 5px;
192 + padding: 0 0.4rem;
193 + font-family: var(--sg-mono);
194 + font-size: 0.72rem;
195 + color: var(--sg-text-soft);
196 + background: var(--sg-bg);
197 +}
198 +
199 +.sg-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
200 +.sg-chip {
201 + appearance: none;
202 + font: inherit;
203 + font-size: 0.8rem;
204 + padding: 0.28rem 0.75rem;
205 + border-radius: 999px;
206 + border: 1px solid var(--sg-border);
207 + background: var(--sg-bg-soft);
208 + color: var(--sg-text-soft);
209 + cursor: pointer;
210 + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
211 +}
212 +.sg-chip:hover { border-color: var(--sg-accent); color: var(--sg-text); text-decoration: none; }
213 +.sg-chip[aria-pressed='true'], .sg-chip.sg-chip-active {
214 + background: var(--sg-accent-soft);
215 + border-color: var(--sg-accent);
216 + color: var(--sg-accent-ink);
217 +}
218 +
219 +/* ---------- gallery grid & cards ---------- */
220 +.sg-grid {
221 + display: grid;
222 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
223 + gap: 1.25rem;
224 + padding-bottom: 2rem;
225 +}
226 +.sg-card {
227 + border: 1px solid var(--sg-border);
228 + border-radius: var(--sg-radius);
229 + background: var(--sg-bg-soft);
230 + overflow: hidden;
231 + transition: transform 0.15s ease, border-color 0.15s ease;
232 + display: flex;
233 + flex-direction: column;
234 +}
235 +.sg-card:hover { transform: translateY(-2px); border-color: var(--sg-accent); }
236 +.sg-card[hidden] { display: none; }
237 +.sg-card-preview {
238 + position: relative;
239 + height: 170px;
240 + background:
241 + linear-gradient(45deg, var(--sg-bg-inset) 25%, transparent 25%, transparent 75%, var(--sg-bg-inset) 75%) 0 0 / 20px 20px,
242 + linear-gradient(45deg, var(--sg-bg-inset) 25%, transparent 25%, transparent 75%, var(--sg-bg-inset) 75%) 10px 10px / 20px 20px,
243 + var(--sg-bg);
244 + border-bottom: 1px solid var(--sg-border);
245 +}
246 +.sg-card-preview iframe {
247 + position: absolute;
248 + inset: 0;
249 + width: 100%;
250 + height: 100%;
251 + border: 0;
252 + background: transparent;
253 +}
254 +.sg-card-body { padding: 0.85rem 1rem 1rem; display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
255 +.sg-card-title {
256 + font-weight: 600;
257 + font-size: 1rem;
258 + color: var(--sg-text);
259 + margin: 0;
260 +}
261 +.sg-card-title a { color: inherit; }
262 +.sg-card-title a::after { content: ''; position: absolute; inset: 0; }
263 +.sg-card { position: relative; }
264 +.sg-card-meta { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; margin-top: auto; }
265 +
266 +.sg-badge {
267 + font-size: 0.72rem;
268 + padding: 0.12rem 0.55rem;
269 + border-radius: 999px;
270 + border: 1px solid var(--sg-border);
271 + color: var(--sg-text-soft);
272 + background: var(--sg-bg);
273 + white-space: nowrap;
274 +}
275 +.sg-badge-cat { border-color: var(--sg-accent); color: var(--sg-accent-ink); background: var(--sg-accent-soft); }
276 +.sg-badge-beginner { color: var(--sg-ok); border-color: currentColor; }
277 +.sg-badge-intermediate { color: var(--sg-accent-ink); border-color: currentColor; }
278 +.sg-badge-advanced { color: var(--sg-err); border-color: currentColor; }
279 +
280 +.sg-empty { padding: 3rem 0; text-align: center; color: var(--sg-text-soft); }
281 +.sg-count { font-size: 0.85rem; color: var(--sg-text-soft); margin-left: auto; }
282 +
283 +/* ---------- detail page ---------- */
284 +.sg-detail-head { padding: 2rem 0 1rem; }
285 +.sg-breadcrumb { font-size: 0.85rem; color: var(--sg-text-soft); margin-bottom: 0.75rem; }
286 +.sg-breadcrumb a { color: inherit; }
287 +.sg-detail-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.6rem 0 0; }
288 +
289 +.sg-detail-grid {
290 + display: grid;
291 + grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr);
292 + gap: 1.5rem;
293 + align-items: start;
294 +}
295 +@media (max-width: 860px) { .sg-detail-grid { grid-template-columns: 1fr; } }
296 +
297 +.sg-panel {
298 + border: 1px solid var(--sg-border);
299 + border-radius: var(--sg-radius);
300 + background: var(--sg-bg-soft);
301 + overflow: hidden;
302 +}
303 +.sg-panel-bar {
304 + display: flex;
305 + align-items: center;
306 + gap: 0.5rem;
307 + padding: 0.55rem 0.9rem;
308 + border-bottom: 1px solid var(--sg-border);
309 + font-size: 0.85rem;
310 + color: var(--sg-text-soft);
311 +}
312 +.sg-panel-bar strong { color: var(--sg-text); font-weight: 600; }
313 +.sg-panel-bar .sg-spacer { margin-left: auto; }
314 +
315 +.sg-preview-stage { position: relative; height: 340px; }
316 +.sg-preview-stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
317 +.sg-bg-toggle { display: inline-flex; border: 1px solid var(--sg-border); border-radius: 999px; overflow: hidden; }
318 +.sg-bg-toggle button {
319 + appearance: none;
320 + border: 0;
321 + background: transparent;
322 + color: var(--sg-text-soft);
323 + font: inherit;
324 + font-size: 0.78rem;
325 + padding: 0.2rem 0.7rem;
326 + cursor: pointer;
327 +}
328 +.sg-bg-toggle button[aria-pressed='true'] { background: var(--sg-accent-soft); color: var(--sg-accent-ink); }
329 +
330 +/* customizer */
331 +.sg-customizer { display: flex; flex-direction: column; gap: 0.9rem; padding: 1rem; }
332 +.sg-control { display: grid; grid-template-columns: 1fr auto; gap: 0.15rem 0.6rem; align-items: center; }
333 +.sg-control label { font-size: 0.85rem; font-weight: 600; }
334 +.sg-control output {
335 + font-family: var(--sg-mono);
336 + font-size: 0.78rem;
337 + color: var(--sg-text-soft);
338 + justify-self: end;
339 +}
340 +.sg-control input[type='range'] { grid-column: 1 / -1; width: 100%; accent-color: var(--sg-accent); }
341 +.sg-control input[type='color'] {
342 + grid-column: 1 / -1;
343 + width: 100%;
344 + height: 32px;
345 + padding: 2px;
346 + border: 1px solid var(--sg-border);
347 + border-radius: 8px;
348 + background: var(--sg-bg);
349 + cursor: pointer;
350 +}
351 +.sg-customizer-actions { display: flex; gap: 0.5rem; }
352 +
353 +/* buttons */
354 +.sg-btn {
355 + appearance: none;
356 + display: inline-flex;
357 + align-items: center;
358 + gap: 0.45rem;
359 + font: inherit;
360 + font-size: 0.875rem;
361 + font-weight: 600;
362 + padding: 0.5rem 1rem;
363 + border-radius: 8px;
364 + border: 1px solid var(--sg-border);
365 + background: var(--sg-bg-soft);
366 + color: var(--sg-text);
367 + cursor: pointer;
368 + transition: border-color 0.15s ease, background 0.15s ease;
369 +}
370 +.sg-btn:hover { border-color: var(--sg-accent); text-decoration: none; }
371 +.sg-btn-primary { background: var(--sg-accent); border-color: var(--sg-accent); color: #fff; }
372 +.sg-btn-primary:hover { background: var(--sg-accent-ink); border-color: var(--sg-accent-ink); }
373 +.sg-btn-sm { padding: 0.3rem 0.7rem; font-size: 0.8rem; }
374 +.sg-btn.sg-btn-done { border-color: var(--sg-ok); color: var(--sg-ok); }
375 +.sg-btn-primary.sg-btn-done { background: var(--sg-ok); color: #fff; }
376 +
377 +/* code block */
378 +.sg-codeblock { position: relative; }
379 +.sg-codeblock pre {
380 + margin: 0;
381 + padding: 1rem 1.1rem;
382 + overflow: auto;
383 + font-family: var(--sg-mono);
384 + font-size: 0.82rem;
385 + line-height: 1.55;
386 + max-height: 560px;
387 + border-radius: 0 0 var(--sg-radius) var(--sg-radius);
388 +}
389 +.sg-codeblock pre code { font-family: inherit; }
390 +
391 +/* how it works */
392 +.sg-prose { max-width: 72ch; }
393 +.sg-prose p { color: var(--sg-text); }
394 +.sg-how {
395 + border-left: 3px solid var(--sg-accent);
396 + background: var(--sg-accent-soft);
397 + border-radius: 0 var(--sg-radius) var(--sg-radius) 0;
398 + padding: 1rem 1.25rem;
399 + margin: 1.25rem 0;
400 +}
401 +.sg-how h2 { font-size: 1.05rem; margin-bottom: 0.35rem; }
402 +.sg-how p { margin: 0; color: var(--sg-text); }
403 +
404 +.sg-section { padding: 1.5rem 0 0; }
405 +.sg-related { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
406 +
407 +/* about page */
408 +.sg-about { max-width: 72ch; padding-top: 2rem; }
409 +.sg-about li { margin-bottom: 0.4em; }
410 +
411 +/* reduced motion: calm the site chrome (snippet previews keep their purpose) */
412 +@media (prefers-reduced-motion: reduce) {
413 + html { scroll-behavior: auto; }
414 + .sg-card, .sg-chip, .sg-btn { transition: none; }
415 +}
416