Astro garden skeleton: Quartz-compatible URLs, Obsidian markdown, odometer homepage

- glob content loader over the vault's Digital_Garden with verbatim-path ids
- remark plugin: wikilinks, image embeds (wiki + relative md), callouts
- URL parity with live Quartz sitemap verified 102/102 (built set is a
  strict superset: +30 synthetic folder listings)
- KaTeX, lazy mermaid, shiki dual themes, quoted-string draft handling
- copy-assets resolves embeds vault-wide (fixes live-site 404s for
  Blog Scratch/assets images) into flat /assets/
- homepage odometer widget: fetches api.c0smere.net baseline, ticks at
  lifetime average rate, fail-closed hidden on error
- deploy/: nginx try_files config + compose (port 18100) + containerized
  build script for cyrion

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wes
2026-07-15 14:18:59 -04:00
co-authored by Claude Opus 4.8
commit 04c9aa2fb2
15 changed files with 7527 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.astro/
public/assets/
+45
View File
@@ -0,0 +1,45 @@
# garden-astro
Astro rebuild of the digital garden at `garden.c0smere.net`, replacing
Quartz v4. Content stays in the Obsidian vault (`Digital_Garden/` folder);
this repo is only the rendering pipeline.
## URL compatibility
Built to be a drop-in replacement — every URL on the live Quartz site
resolves identically here (verified against the live sitemap, 102/102):
- case preserved, spaces become `-`, hyphens/em-dashes kept
- leaf pages: `/Folder/Note-Name` (`build.format: 'file'` + nginx
`try_files $uri $uri.html`)
- folder indexes: `/Folder/` (from `Folder/index.md`, or a synthetic
listing page when no index exists)
## Obsidian compatibility (`src/lib/remark-obsidian.mjs`)
- `[[wikilinks]]` with aliases/anchors, resolved vault-path-first then by
basename; unresolved links degrade to plain text
- `![[image embeds]]` and relative `![md](images)` both rewrite to flat
`/assets/<name>` URLs; `scripts/copy-assets.mjs` resolves the files
(Digital_Garden first, then vault-wide) and copies them in at build time
- `> [!type]` callouts, KaTeX math, mermaid (lazy-loaded client-side only
on pages that use it), shiki dual light/dark themes
- `draft:` frontmatter excluded, including Quartz-style quoted `"true"`
## Build
```sh
GARDEN_CONTENT=/path/to/vault/Digital_Garden npm run build
```
Defaults to the kotov vault copy. On cyrion use `deploy/build.sh`
(containerized node:24, vault mounted read-only), then
`docker compose -f deploy/docker-compose.yml up -d` serves `dist/` on
port 18100 (`garden-next.c0smere.net` while staging).
## Homepage extras
`src/pages/index.astro` renders the vault's `index.md` and appends the
bandwidth odometer, fed by `https://api.c0smere.net/bandwidth/odometer`.
The widget animates at the lifetime average rate from a fetched baseline —
it never reflects live throughput, and hides itself if the API is down.
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'astro/config';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { remarkObsidian } from './src/lib/remark-obsidian.mjs';
import { buildSlugMap, CONTENT_DIR } from './src/lib/quartz-compat.mjs';
const slugMap = buildSlugMap(CONTENT_DIR);
export default defineConfig({
site: 'https://garden.c0smere.net',
// 'file' => /A/B.html served as /A/B — matches Quartz's URL shape
build: { format: 'file' },
markdown: {
remarkPlugins: [[remarkObsidian, { slugMap }], remarkMath],
rehypePlugins: [rehypeKatex],
shikiConfig: {
themes: { light: 'github-light', dark: 'github-dark' },
},
},
});
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Containerized build for cyrion: mounts the Obsidian vault read-only and
# writes dist/ into the repo checkout. Run from the repo root.
set -eu
VAULT="${VAULT:-/home/nox/docker/obsidian/vaults/weeslahw_coppermind}"
docker run --rm \
-v "$(pwd):/app" \
-v "$VAULT:/content:ro" \
-e GARDEN_CONTENT=/content/Digital_Garden \
-e ASTRO_TELEMETRY_DISABLED=1 \
-w /app \
node:24-slim \
sh -c "npm ci && npm run build"
+11
View File
@@ -0,0 +1,11 @@
# Serves the prebuilt dist/ — run deploy/build.sh first.
services:
garden-next:
image: nginx:alpine
container_name: garden-next
restart: unless-stopped
ports:
- "18100:80"
volumes:
- ../dist:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+23
View File
@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
charset utf-8;
# Quartz-compatible URLs: /A/B -> A/B.html, /Folder/ -> Folder/index.html
location / {
try_files $uri $uri.html $uri/index.html =404;
}
location /assets/ {
expires 7d;
add_header Cache-Control "public";
}
location /_astro/ {
# content-hashed filenames — safe to cache hard
expires 365d;
add_header Cache-Control "public, immutable";
}
}
+6737
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "garden-astro",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "node scripts/copy-assets.mjs && astro build",
"preview": "astro preview"
},
"dependencies": {
"astro": "^5.0.0",
"github-slugger": "^2.0.0",
"katex": "^0.16.10",
"mermaid": "^11.0.0",
"rehype-katex": "^7.0.0",
"remark-math": "^6.0.0",
"unist-util-visit": "^5.0.0"
},
"allowScripts": {
"esbuild@0.27.7": true,
"esbuild@0.25.12": true,
"sharp@0.34.5": true
}
}
+85
View File
@@ -0,0 +1,85 @@
// Collect every image referenced by an Obsidian embed (![[...]]) in the
// garden content and copy it to public/assets/ flat by basename — matching
// the /assets/<name> URLs the remark plugin emits. Resolution order:
// Digital_Garden first, then the wider vault (some embeds point at files
// that live outside the published folder; the live Quartz site 404s those —
// we fix them, but loudly, so stray references are visible).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CONTENT_DIR,
VAULT_ROOT,
walkMarkdown,
} from '../src/lib/quartz-compat.mjs';
const OUT_DIR = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'public',
'assets',
);
const EMBED_RE = /!\[\[([^\]|]+?\.(?:png|jpe?g|gif|svg|webp))(?:\|[^\]]*)?\]\]/gi;
const MD_IMG_RE = /!\[[^\]]*\]\(([^)\s]+?\.(?:png|jpe?g|gif|svg|webp))\)/gi;
function walkFiles(dir) {
const out = [];
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
if (ent.name.startsWith('.')) continue;
const full = path.join(dir, ent.name);
if (ent.isDirectory()) out.push(...walkFiles(full));
else out.push(full);
}
return out;
}
// referenced image names (basename, as Obsidian resolves vault-wide)
const wanted = new Map(); // lowercase basename -> name as referenced
for (const rel of walkMarkdown(CONTENT_DIR)) {
const text = fs.readFileSync(path.join(CONTENT_DIR, rel), 'utf8');
for (const m of text.matchAll(EMBED_RE)) {
const name = m[1].split('/').pop().trim();
wanted.set(name.toLowerCase(), name);
}
for (const m of text.matchAll(MD_IMG_RE)) {
if (/^(?:[a-z]+:)?\/\//i.test(m[1])) continue;
const name = decodeURIComponent(m[1]).split('/').pop().trim();
wanted.set(name.toLowerCase(), name);
}
}
// index every file in the vault by lowercase basename
const inDG = new Map();
const inVault = new Map();
for (const f of walkFiles(VAULT_ROOT)) {
const key = path.basename(f).toLowerCase();
if (f.startsWith(CONTENT_DIR + path.sep)) {
if (!inDG.has(key)) inDG.set(key, f);
} else if (!inVault.has(key)) {
inVault.set(key, f);
}
}
fs.rmSync(OUT_DIR, { recursive: true, force: true });
fs.mkdirSync(OUT_DIR, { recursive: true });
let copied = 0;
let missing = 0;
for (const [key, name] of wanted) {
const src = inDG.get(key) ?? inVault.get(key);
if (!src) {
console.warn(`copy-assets: UNRESOLVED image embed: ${name}`);
missing++;
continue;
}
if (!inDG.has(key)) {
console.warn(
`copy-assets: ${name} lives outside Digital_Garden (${path.relative(VAULT_ROOT, src)})`,
);
}
fs.copyFileSync(src, path.join(OUT_DIR, name));
copied++;
}
console.log(`copy-assets: ${copied} copied, ${missing} unresolved -> ${OUT_DIR}`);
if (missing) process.exitCode = 0; // warn, don't fail the build
+27
View File
@@ -0,0 +1,27 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
import { CONTENT_DIR } from './lib/quartz-compat.mjs';
const garden = defineCollection({
loader: glob({
pattern: ['**/*.md', '!**/assets/**'],
base: CONTENT_DIR,
// keep the vault-relative path verbatim (spaces + case) as the id;
// slugging to Quartz-compatible URLs happens in quartz-compat.mjs
generateId: ({ entry }) => entry.replace(/\.md$/, ''),
}),
schema: z
.object({
title: z.string().optional(),
description: z.string().optional(),
tags: z.union([z.array(z.string()), z.string()]).optional(),
// vault uses quoted YAML ("true"/"false") in places; treat any
// truthy value except the literal "false" as draft, like Quartz did
draft: z
.preprocess((v) => (typeof v === 'string' ? v !== 'false' : v), z.boolean())
.optional(),
})
.passthrough(),
});
export const collections = { garden };
+167
View File
@@ -0,0 +1,167 @@
---
import 'katex/dist/katex.min.css';
interface Props {
title: string;
description?: string;
}
const { title, description } = Astro.props;
const isHome = Astro.url.pathname === '/';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}{isHome ? '' : ' // Wesley Ray'}</title>
{description && <meta name="description" content={description} />}
<script
defer
data-domain="garden.c0smere.net"
src="https://plausible.io/js/script.js"></script>
</head>
<body>
<header>
{!isHome && <nav><a href="/">← home</a></nav>}
</header>
<main>
<slot />
</main>
<footer>
<p>
Wesley Ray · <a href="https://blog.c0smere.net">blog</a> ·
<a href="https://git.c0smere.net/wes">git</a> ·
<a href="https://resume.c0smere.net">resume</a>
</p>
</footer>
<script>
// mermaid: only load the (large) module on pages that need it
const blocks = document.querySelectorAll('pre > code.language-mermaid');
if (blocks.length) {
const { default: mermaid } = await import('mermaid');
blocks.forEach((code) => {
const pre = document.createElement('pre');
pre.className = 'mermaid';
pre.textContent = code.textContent ?? '';
code.parentElement?.replaceWith(pre);
});
mermaid.initialize({
startOnLoad: false,
theme: matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'default',
});
await mermaid.run();
}
</script>
<style is:global>
:root {
--bg: #faf8f8;
--fg: #2b2b2b;
--muted: #4e4e4e;
--faint: #e5e5e5;
--accent: #284b63;
--accent2: #84a59d;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #161618;
--fg: #ebebec;
--muted: #d4d4d4;
--faint: #393639;
--accent: #7b97aa;
--accent2: #84a59d;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
max-width: 44rem;
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.6;
}
a {
color: var(--accent);
}
h1, h2, h3, h4 {
line-height: 1.25;
}
header nav {
margin-bottom: 1rem;
font-size: 0.9rem;
}
footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid var(--faint);
font-size: 0.85rem;
color: var(--muted);
}
img {
max-width: 100%;
height: auto;
}
pre {
padding: 0.9rem 1rem;
border-radius: 6px;
overflow-x: auto;
font-size: 0.88rem;
}
:not(pre) > code {
background: var(--faint);
padding: 0.1em 0.35em;
border-radius: 4px;
font-size: 0.9em;
}
table {
border-collapse: collapse;
display: block;
overflow-x: auto;
max-width: 100%;
}
th, td {
border: 1px solid var(--faint);
padding: 0.35rem 0.6rem;
}
blockquote {
margin: 1rem 0;
padding: 0.1rem 1rem;
border-left: 3px solid var(--accent2);
color: var(--muted);
}
.callout {
border-left-width: 4px;
border-radius: 4px;
background: color-mix(in srgb, var(--accent2) 8%, transparent);
}
.callout-title {
color: var(--accent);
}
.callout-warning, .callout-attention, .callout-important {
border-left-color: #c9803a;
}
.callout-warning .callout-title,
.callout-attention .callout-title,
.callout-important .callout-title {
color: #c9803a;
}
/* dark-mode-aware shiki dual themes */
@media (prefers-color-scheme: dark) {
.astro-code,
.astro-code span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
}
</style>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
// URL/slug compatibility with the outgoing Quartz v4 site.
// Verified against the live sitemap 2026-07-15: case is preserved, every
// space becomes "-", existing hyphens/em-dashes are kept, folder index
// pages are served at "<Folder>/".
import fs from 'node:fs';
import path from 'node:path';
export const CONTENT_DIR =
process.env.GARDEN_CONTENT ||
'/home/wes/Documents/weeslahw_coppermind/Digital_Garden';
export const VAULT_ROOT = path.dirname(CONTENT_DIR);
export function slugSegment(seg) {
return seg.replace(/\s+/g, '-');
}
// id = vault-relative path without ".md", verbatim (spaces, case intact)
export function slugForId(id) {
return id.split('/').map(slugSegment).join('/');
}
export function urlForSlug(slug) {
if (slug === 'index') return '/';
if (slug.endsWith('/index')) return '/' + slug.slice(0, -'index'.length);
return '/' + slug;
}
export function urlForId(id) {
return urlForSlug(slugForId(id));
}
export function walkMarkdown(dir, rel = '') {
const out = [];
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
if (ent.name.startsWith('.')) continue;
const full = path.join(dir, ent.name);
const r = rel ? `${rel}/${ent.name}` : ent.name;
if (ent.isDirectory()) {
if (ent.name === 'assets') continue;
out.push(...walkMarkdown(full, r));
} else if (ent.name.endsWith('.md')) {
out.push(r);
}
}
return out;
}
// Wikilink resolution map: lowercase full path and lowercase basename -> slug
export function buildSlugMap(base = CONTENT_DIR) {
const map = new Map();
for (const f of walkMarkdown(base)) {
const id = f.replace(/\.md$/, '');
const slug = slugForId(id);
map.set(id.toLowerCase(), slug);
const basename = id.split('/').pop().toLowerCase();
if (!map.has(basename)) map.set(basename, slug);
}
return map;
}
+120
View File
@@ -0,0 +1,120 @@
// Obsidian-flavored markdown for the garden: wikilinks, image embeds,
// callouts. Unresolvable wikilinks degrade to plain text — a bad link
// must never break a page.
import { visit } from 'unist-util-visit';
import GithubSlugger from 'github-slugger';
import { urlForSlug } from './quartz-compat.mjs';
const WIKI_RE = /(!?)\[\[([^\]]+)\]\]/g;
const IMG_RE = /\.(png|jpe?g|gif|svg|webp)$/i;
function slugAnchor(text) {
return new GithubSlugger().slug(text);
}
function wikiNode(isEmbed, inner, slugMap) {
let [targetPart, ...aliasParts] = inner.split('|');
const alias = aliasParts.length ? aliasParts.join('|').trim() : null;
const hash = targetPart.indexOf('#');
const target = (hash === -1 ? targetPart : targetPart.slice(0, hash)).trim();
const anchor = hash === -1 ? null : targetPart.slice(hash + 1).trim();
if (isEmbed && IMG_RE.test(target)) {
const name = target.split('/').pop();
return {
type: 'image',
url: '/assets/' + encodeURIComponent(name),
alt: alias || name,
};
}
const display = alias || (target ? target.split('/').pop() : '#' + anchor);
const frag = anchor ? '#' + slugAnchor(anchor) : '';
if (!target) {
// same-page anchor: [[#Heading]]
return { type: 'link', url: frag, children: [{ type: 'text', value: display }] };
}
const key = target.replace(/\.md$/i, '').toLowerCase();
const slug = slugMap.get(key) || slugMap.get(key.split('/').pop());
if (!slug) {
return { type: 'text', value: display };
}
return {
type: 'link',
url: urlForSlug(slug) + frag,
children: [{ type: 'text', value: display }],
};
}
function transformCallout(node) {
const first = node.children?.[0];
if (!first || first.type !== 'paragraph') return;
const t = first.children?.[0];
if (!t || t.type !== 'text') return;
const nl = t.value.indexOf('\n');
const firstLine = nl === -1 ? t.value : t.value.slice(0, nl);
const m = /^\[!(\w+)\][+-]?\s*(.*)$/.exec(firstLine);
if (!m) return;
const type = m[1].toLowerCase();
const title = m[2].trim() || type.charAt(0).toUpperCase() + type.slice(1);
t.value = nl === -1 ? '' : t.value.slice(nl + 1);
if (!t.value) {
first.children.shift();
if (first.children[0]?.type === 'break') first.children.shift();
if (first.children.length === 0) node.children.shift();
}
node.children.unshift({
type: 'paragraph',
data: { hProperties: { className: ['callout-title'] } },
children: [{ type: 'strong', children: [{ type: 'text', value: title }] }],
});
node.data = {
...node.data,
hProperties: {
className: ['callout', `callout-${type}`],
'data-callout': type,
},
};
}
export function remarkObsidian(options = {}) {
const { slugMap = new Map() } = options;
return (tree) => {
visit(tree, 'blockquote', transformCallout);
// relative markdown-syntax images (e.g. ![x](assets/foo.svg)) also go
// through the flat /assets/ pipeline — keeps Astro's image resolver
// out of it and fixes vault refs that point outside Digital_Garden
visit(tree, 'image', (node) => {
if (/^(?:[a-z]+:)?\/\//i.test(node.url) || node.url.startsWith('/')) return;
const name = decodeURIComponent(node.url).split('/').pop();
node.url = '/assets/' + encodeURIComponent(name);
});
visit(tree, 'text', (node, index, parent) => {
if (!parent || parent.type === 'link' || index === undefined) return;
WIKI_RE.lastIndex = 0;
if (!WIKI_RE.test(node.value)) return;
const parts = [];
let last = 0;
let m;
WIKI_RE.lastIndex = 0;
while ((m = WIKI_RE.exec(node.value))) {
if (m.index > last)
parts.push({ type: 'text', value: node.value.slice(last, m.index) });
parts.push(wikiNode(m[1] === '!', m[2], slugMap));
last = m.index + m[0].length;
}
if (last < node.value.length)
parts.push({ type: 'text', value: node.value.slice(last) });
parent.children.splice(index, 1, ...parts);
return index + parts.length;
});
};
}
+79
View File
@@ -0,0 +1,79 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../layouts/Base.astro';
import { slugForId, urlForId } from '../lib/quartz-compat.mjs';
export async function getStaticPaths() {
const entries = await getCollection('garden', (e) => !e.data.draft);
const ids = new Set(entries.map((e) => e.id));
const paths = entries
.filter((e) => e.id !== 'index')
.map((e) => ({
params: { slug: slugForId(e.id) },
props: { entry: e, listing: null },
}));
// synthetic listing pages for folders that have no index.md
const folders = new Set<string>();
for (const id of ids) {
let d = id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : '';
while (d) {
folders.add(d);
d = d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '';
}
}
for (const folder of folders) {
if (ids.has(`${folder}/index`)) continue;
const children = entries
.filter((e) => {
const dir = e.id.includes('/') ? e.id.slice(0, e.id.lastIndexOf('/')) : '';
return dir === folder && !e.id.endsWith('/index');
})
.map((e) => ({
title: e.data.title ?? e.id.split('/').pop(),
url: urlForId(e.id),
}));
const subfolders = [...folders]
.filter((f) => {
const dir = f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : '';
return dir === folder;
})
.map((f) => ({
title: f.split('/').pop(),
url: '/' + slugForId(f) + '/',
}));
paths.push({
params: { slug: slugForId(folder) + '/index' },
props: {
entry: null,
listing: { name: folder.split('/').pop(), items: [...subfolders, ...children] },
},
});
}
return paths;
}
const { entry, listing } = Astro.props;
const title = entry
? (entry.data.title ?? entry.id.split('/').pop())
: listing.name;
const Content = entry ? (await render(entry)).Content : null;
---
<Base title={title} description={entry?.data.description}>
<h1>{title}</h1>
{Content && <Content />}
{
listing && (
<ul>
{listing.items.map((item) => (
<li>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)
}
</Base>
+109
View File
@@ -0,0 +1,109 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../layouts/Base.astro';
const entries = await getCollection('garden');
const home = entries.find((e) => e.id === 'index');
const Content = home ? (await render(home)).Content : null;
const title = home?.data.title ?? 'Wesley Ray';
---
<Base title={title} description={home?.data.description}>
{Content && <Content />}
<section id="odometer" hidden>
<h2>Bandwidth odometer</h2>
<p class="odo-lede">
Every byte in or out of the homelab since <span id="odo-since"></span>:
</p>
<div class="odo-display" id="odo-digits" aria-live="off"></div>
<p class="odo-foot">
ticking at the lifetime average rate · served by
<a href="https://api.c0smere.net/docs">api.c0smere.net</a>
</p>
</section>
<script>
// Odometer: fetch totals once, then animate forward at the lifetime
// average rate. Looks live, but deliberately never exposes current
// throughput. Fail-closed: any error leaves the section hidden.
const API = 'https://api.c0smere.net/bandwidth/odometer';
const section = document.getElementById('odometer')!;
const digitsEl = document.getElementById('odo-digits')!;
const sinceEl = document.getElementById('odo-since')!;
function renderDigits(total: bigint) {
const s = total.toString();
// group into 3s for readability, each digit in its own cell
let html = '';
for (let i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 === 0) html += '<span class="odo-sep"></span>';
html += `<span class="odo-digit">${s[i]}</span>`;
}
digitsEl.innerHTML = html;
}
try {
const res = await fetch(API);
if (!res.ok) throw new Error(String(res.status));
const data = await res.json();
const base = BigInt(data.total_bytes);
const rate = Number(data.avg_bytes_per_sec); // bytes/sec
const asOf = Date.parse(data.as_of);
if (!Number.isFinite(rate) || !Number.isFinite(asOf)) throw new Error('bad payload');
sinceEl.textContent = new Date(data.since).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const tick = () => {
const elapsed = (Date.now() - asOf) / 1000;
renderDigits(base + BigInt(Math.floor(elapsed * rate)));
};
tick();
section.hidden = false;
setInterval(tick, 100);
} catch {
/* API down or blocked: section stays hidden */
}
</script>
<style>
#odometer {
margin-top: 2.5rem;
padding-top: 1rem;
border-top: 1px solid var(--faint);
}
.odo-lede {
color: var(--muted);
margin-bottom: 0.5rem;
}
.odo-display {
display: flex;
flex-wrap: wrap;
gap: 2px;
font-family: ui-monospace, 'Cascadia Mono', 'JetBrains Mono', monospace;
font-size: clamp(1.1rem, 4vw, 1.8rem);
font-variant-numeric: tabular-nums;
}
.odo-display :global(.odo-digit) {
display: inline-block;
min-width: 1.35ch;
padding: 0.15em 0.1em;
text-align: center;
background: #1c1c1e;
color: #f2f2f3;
border-radius: 4px;
border-bottom: 2px solid #000;
}
.odo-display :global(.odo-sep) {
width: 0.35ch;
}
.odo-foot {
font-size: 0.8rem;
color: var(--muted);
}
</style>
</Base>