feat(site): gallery, detail pages, customizer, search, filters, dark mode
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 & 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><title></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 | ||