frieren

~ / frieren

a self-hosted git server in one binary — everyone reads, only the owner writes

feat(web): Next.js frontend for the archive

9bc8cf25e7e31fc939c7d6a42e28c5f717c9a47d

justin06lee · Aug 18, 2026 16:24 (4h ago)

 Makefile                                       |   5 +-
 README.md                                      |  17 +
 web/.gitignore                                 |  41 ++
 web/app/[repo]/blob/[ref]/[...path]/page.tsx   |  66 ++++
 web/app/[repo]/commit/[hash]/page.tsx          |  33 ++
 web/app/[repo]/commits/[[...ref]]/page.tsx     |  46 +++
 web/app/[repo]/layout.tsx                      |  40 ++
 web/app/[repo]/page.tsx                        |  47 +++
 web/app/[repo]/refs/page.tsx                   |  62 +++
 web/app/[repo]/tree/[ref]/[[...path]]/page.tsx |  23 ++
 web/app/error.tsx                              |  20 +
 web/app/globals.css                            | 130 +++++++
 web/app/icon.svg                               |  12 +
 web/app/layout.tsx                             |  46 +++
 web/app/not-found.tsx                          |  15 +
 web/app/page.tsx                               |  63 ++++
 web/bun.lock                                   | 497 +++++++++++++++++++++++++
 web/components/CloneBox.tsx                    |  26 ++
 web/components/Crumbs.tsx                      |  41 ++
 web/components/Diff.tsx                        |  56 +++
 web/components/Markdown.tsx                    |  43 +++
 web/components/Offline.tsx                     |  31 ++
 web/components/RepoNav.tsx                     |  46 +++
 web/components/Starfield.tsx                   |  34 ++
 web/components/TreeTable.tsx                   |  63 ++++
 web/lib/api.ts                                 |  96 +++++
 web/lib/diff.ts                                |  71 ++++
 web/lib/format.ts                              |  31 ++
 web/lib/params.ts                              |  13 +
 web/lib/shiki.ts                               |  46 +++
 web/next.config.ts                             |   7 +
 web/package.json                               |  37 ++
 web/postcss.config.mjs                         |   7 +
 web/tsconfig.json                              |  34 ++
 34 files changed, 1844 insertions(+), 1 deletion(-)

diff --git a/Makefile b/Makefile
index 6baabd1..b348798 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
 VERSION     := $(shell git describe --tags --match 'v*' --always --dirty 2>/dev/null || echo dev)
 INSTALL_DIR := $(HOME)/.local/bin
 
-.PHONY: all build install update test clean
+.PHONY: all build install update test web clean
 
 all: build install
 
@@ -21,5 +21,8 @@ update: all
 test:
 	go test ./...
 
+web:
+	cd web && bun install && bun run build
+
 clean:
 	rm -rf dist
diff --git a/README.md b/README.md
index 67e9be0..ab3efe1 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,23 @@ GET /api/repos/{name}/refs              branches and tags
 
 Omitting `ref` uses the repository's default branch.
 
+## The frontend (`web/`)
+
+`web/` is a Next.js app that reads that API and turns the archive into a designed reading experience — serif hero, syntax-highlighted files (shiki), rendered READMEs with working relative images, per-file diff views with dual line numbers. It fetches server-side, so the backend needs no CORS and its address stays out of the browser.
+
+```sh
+cd web
+bun install
+FRIEREN_API_URL=http://localhost:7420 bun dev
+```
+
+It deploys anywhere Next.js runs; on Vercel, set two things on the project:
+
+- `FRIEREN_API_URL` — the public URL of your frieren backend (e.g. `https://git.example.com`)
+- `FRIEREN_CLONE_URL` — optional; shown in clone commands when it differs from the API URL
+
+Until the backend is reachable, the site renders a graceful "archive unreachable" state with setup instructions, and recovers on its own once the server answers.
+
 ## What it deliberately isn't
 
 No issues, no pull requests, no user accounts, no markdown rendering yet — it hosts and shows git repositories, and stops there. The single-writer model is the point: if you need collaborators with write access, you want a full forge like Forgejo.
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..5ef6a52
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/web/app/[repo]/blob/[ref]/[...path]/page.tsx b/web/app/[repo]/blob/[ref]/[...path]/page.tsx
new file mode 100644
index 0000000..6524531
--- /dev/null
+++ b/web/app/[repo]/blob/[ref]/[...path]/page.tsx
@@ -0,0 +1,66 @@
+import { notFound } from "next/navigation";
+import { getBlob, rawUrl } from "@/lib/api";
+import { dec, decPath } from "@/lib/params";
+import { byteSize } from "@/lib/format";
+import { highlight, langForFile } from "@/lib/shiki";
+import Crumbs from "@/components/Crumbs";
+
+type Props = { params: Promise<{ repo: string; ref: string; path: string[] }> };
+
+export default async function BlobPage({ params }: Props) {
+  const p = await params;
+  const repo = dec(p.repo);
+  const ref = dec(p.ref);
+  const path = decPath(p.path);
+  const blob = await getBlob(repo, ref, path);
+  if (!blob) notFound();
+
+  const html = blob.binary || blob.truncated ? null : await highlight(blob.content, langForFile(path));
+
+  return (
+    <div className="space-y-4">
+      <div className="flex flex-wrap items-baseline justify-between gap-3">
+        <Crumbs repo={repo} refName={ref} path={path} leafIsLink={false} />
+        <p className="font-mono text-xs text-dim">
+          {byteSize(blob.size)} ·{" "}
+          <a href={rawUrl(repo, ref, path)} className="text-fog hover:text-frost">
+            raw
+          </a>
+        </p>
+      </div>
+      {blob.binary ? (
+        <p className="border border-line bg-panel px-4 py-8 text-sm text-fog">
+          Binary file.{" "}
+          <a href={rawUrl(repo, ref, path)} className="text-frost hover:underline">
+            Download the raw bytes.
+          </a>
+        </p>
+      ) : blob.truncated ? (
+        <p className="border border-line bg-panel px-4 py-8 text-sm text-fog">
+          Too large to display here.{" "}
+          <a href={rawUrl(repo, ref, path)} className="text-frost hover:underline">
+            View raw instead.
+          </a>
+        </p>
+      ) : html ? (
+        <div
+          className="overflow-x-auto border border-line bg-abyss px-0 py-3 [&_pre]:px-4"
+          dangerouslySetInnerHTML={{ __html: html }}
+        />
+      ) : (
+        <div className="overflow-x-auto border border-line bg-abyss px-4 py-3">
+          <pre className="shiki">
+            <code>
+              {blob.content.replace(/\n$/, "").split("\n").map((line, i) => (
+                <span key={i} className="line">
+                  {line}
+                  {"\n"}
+                </span>
+              ))}
+            </code>
+          </pre>
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/web/app/[repo]/commit/[hash]/page.tsx b/web/app/[repo]/commit/[hash]/page.tsx
new file mode 100644
index 0000000..8699f4b
--- /dev/null
+++ b/web/app/[repo]/commit/[hash]/page.tsx
@@ -0,0 +1,33 @@
+import { notFound } from "next/navigation";
+import { getCommit } from "@/lib/api";
+import { dec } from "@/lib/params";
+import { fullDate, timeAgo } from "@/lib/format";
+import Diff from "@/components/Diff";
+
+type Props = { params: Promise<{ repo: string; hash: string }> };
+
+export default async function CommitPage({ params }: Props) {
+  const p = await params;
+  const repo = dec(p.repo);
+  const commit = await getCommit(repo, dec(p.hash));
+  if (!commit) notFound();
+
+  return (
+    <div className="space-y-6">
+      <header className="border border-line bg-panel px-5 py-4">
+        <h2 className="text-lg text-snow">{commit.subject}</h2>
+        <p className="mt-2 font-mono text-xs text-dim">{commit.hash}</p>
+        <p className="mt-1 font-mono text-xs text-fog">
+          {commit.author} · {fullDate(commit.when)} ({timeAgo(commit.when)})
+        </p>
+      </header>
+      <Diff patch={commit.patch} />
+      {commit.truncated && (
+        <p className="font-mono text-xs text-dim">
+          diff truncated — see the full change locally with{" "}
+          <code className="text-fog">git show {commit.short}</code>
+        </p>
+      )}
+    </div>
+  );
+}
diff --git a/web/app/[repo]/commits/[[...ref]]/page.tsx b/web/app/[repo]/commits/[[...ref]]/page.tsx
new file mode 100644
index 0000000..4d7614d
--- /dev/null
+++ b/web/app/[repo]/commits/[[...ref]]/page.tsx
@@ -0,0 +1,46 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getRepo, getCommits } from "@/lib/api";
+import { dec, decPath } from "@/lib/params";
+import { timeAgo } from "@/lib/format";
+
+type Props = { params: Promise<{ repo: string; ref?: string[] }> };
+
+export default async function CommitsPage({ params }: Props) {
+  const p = await params;
+  const repo = dec(p.repo);
+  const info = await getRepo(repo);
+  if (!info) notFound();
+  const ref = p.ref?.length ? decPath(p.ref) : info.default;
+  const commits = await getCommits(repo, ref);
+  if (!commits) notFound();
+
+  return (
+    <div className="space-y-4">
+      <p className="font-mono text-xs text-dim">
+        history of <span className="text-frost">{ref}</span>
+        {commits.length === 100 && " · latest 100"}
+      </p>
+      {commits.length === 0 ? (
+        <p className="border border-line bg-panel px-4 py-8 text-sm text-fog">No commits yet.</p>
+      ) : (
+        <div className="border border-line bg-panel">
+          {commits.map((c) => (
+            <Link
+              key={c.hash}
+              href={`/${repo}/commit/${c.hash}`}
+              className="flex items-baseline gap-4 border-b border-line px-4 py-2.5 text-sm last:border-b-0 hover:bg-raise"
+            >
+              <span className="shrink-0 font-mono text-xs text-gold/80">{c.short}</span>
+              <span className="min-w-0 flex-1 truncate text-snow">{c.subject}</span>
+              <span className="hidden shrink-0 font-mono text-xs text-dim sm:inline">
+                {c.author}
+              </span>
+              <span className="shrink-0 font-mono text-xs text-dim">{timeAgo(c.when)}</span>
+            </Link>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/web/app/[repo]/layout.tsx b/web/app/[repo]/layout.tsx
new file mode 100644
index 0000000..1e72c35
--- /dev/null
+++ b/web/app/[repo]/layout.tsx
@@ -0,0 +1,40 @@
+import { notFound } from "next/navigation";
+import type { Metadata } from "next";
+import { getRepo, cloneUrl, BackendOffline } from "@/lib/api";
+import { dec } from "@/lib/params";
+import RepoNav from "@/components/RepoNav";
+import CloneBox from "@/components/CloneBox";
+import Offline from "@/components/Offline";
+
+type Props = { children: React.ReactNode; params: Promise<{ repo: string }> };
+
+export async function generateMetadata({ params }: { params: Promise<{ repo: string }> }): Promise<Metadata> {
+  const { repo } = await params;
+  return { title: dec(repo) };
+}
+
+export default async function RepoLayout({ children, params }: Props) {
+  const name = dec((await params).repo);
+  let repo;
+  try {
+    repo = await getRepo(name);
+  } catch (e) {
+    if (e instanceof BackendOffline) return <Offline />;
+    throw e;
+  }
+  if (!repo) notFound();
+
+  return (
+    <div>
+      <div className="mb-6 flex flex-wrap items-start justify-between gap-4">
+        <div>
+          <h1 className="font-display text-3xl">{repo.name}</h1>
+          {repo.description && <p className="mt-1 text-sm text-fog">{repo.description}</p>}
+        </div>
+        <CloneBox url={cloneUrl(repo.name)} />
+      </div>
+      <RepoNav repo={repo.name} defaultBranch={repo.default} />
+      <div className="pt-6">{children}</div>
+    </div>
+  );
+}
diff --git a/web/app/[repo]/page.tsx b/web/app/[repo]/page.tsx
new file mode 100644
index 0000000..4415bb6
--- /dev/null
+++ b/web/app/[repo]/page.tsx
@@ -0,0 +1,47 @@
+import { notFound } from "next/navigation";
+import { getRepo, getTree, getReadme, cloneUrl } from "@/lib/api";
+import { dec } from "@/lib/params";
+import { timeAgo } from "@/lib/format";
+import TreeTable from "@/components/TreeTable";
+import Markdown from "@/components/Markdown";
+
+export default async function RepoOverview({ params }: { params: Promise<{ repo: string }> }) {
+  const name = dec((await params).repo);
+  const repo = await getRepo(name);
+  if (!repo) notFound();
+
+  if (repo.empty) {
+    return (
+      <div className="border border-line bg-panel px-6 py-10">
+        <p className="font-mono text-sm text-gold">❄ an empty vessel</p>
+        <p className="mt-3 text-sm text-fog">The owner hasn&apos;t pushed anything yet:</p>
+        <pre className="mt-4 overflow-x-auto border border-line bg-abyss px-4 py-3 font-mono text-xs text-fog">
+          {`git remote add frieren ${cloneUrl(repo.name)}\ngit push frieren ${repo.default}`}
+        </pre>
+      </div>
+    );
+  }
+
+  const [entries, readme] = await Promise.all([
+    getTree(name, repo.default, ""),
+    getReadme(name, repo.default),
+  ]);
+
+  return (
+    <div className="space-y-8">
+      <p className="font-mono text-xs text-dim">
+        <span className="text-gold/70">▪</span> {repo.default} · last commit{" "}
+        {timeAgo(repo.lastCommit)}
+      </p>
+      <TreeTable repo={repo.name} refName={repo.default} dir="" entries={entries ?? []} />
+      {readme && (
+        <section>
+          <h2 className="mb-3 border-b border-line pb-2 font-mono text-xs text-dim">
+            {readme.name}
+          </h2>
+          <Markdown repo={repo.name} refName={repo.default} source={readme.content} />
+        </section>
+      )}
+    </div>
+  );
+}
diff --git a/web/app/[repo]/refs/page.tsx b/web/app/[repo]/refs/page.tsx
new file mode 100644
index 0000000..c2ba0ba
--- /dev/null
+++ b/web/app/[repo]/refs/page.tsx
@@ -0,0 +1,62 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getRefs } from "@/lib/api";
+import { dec } from "@/lib/params";
+import { timeAgo } from "@/lib/format";
+
+type Props = { params: Promise<{ repo: string }> };
+
+function RefList({
+  repo,
+  kind,
+  refs,
+}: {
+  repo: string;
+  kind: "branch" | "tag";
+  refs: { name: string; short: string; when: string; subject: string }[];
+}) {
+  return (
+    <section>
+      <h2 className="mb-3 font-mono text-xs text-dim">
+        {kind === "branch" ? "branches" : "tags"}
+      </h2>
+      {refs.length === 0 ? (
+        <p className="text-sm text-dim">none</p>
+      ) : (
+        <div className="border border-line bg-panel">
+          {refs.map((r) => (
+            <div
+              key={r.name}
+              className="flex items-baseline gap-4 border-b border-line px-4 py-2.5 text-sm last:border-b-0"
+            >
+              {kind === "branch" ? (
+                <Link
+                  href={`/${repo}/tree/${encodeURIComponent(r.name)}`}
+                  className="shrink-0 font-mono text-frost hover:underline"
+                >
+                  {r.name}
+                </Link>
+              ) : (
+                <span className="shrink-0 font-mono text-gold/90">{r.name}</span>
+              )}
+              <span className="min-w-0 flex-1 truncate text-fog">{r.subject}</span>
+              <span className="shrink-0 font-mono text-xs text-dim">{timeAgo(r.when)}</span>
+            </div>
+          ))}
+        </div>
+      )}
+    </section>
+  );
+}
+
+export default async function RefsPage({ params }: Props) {
+  const repo = dec((await params).repo);
+  const refs = await getRefs(repo);
+  if (!refs) notFound();
+  return (
+    <div className="space-y-8">
+      <RefList repo={repo} kind="branch" refs={refs.branches} />
+      <RefList repo={repo} kind="tag" refs={refs.tags} />
+    </div>
+  );
+}
diff --git a/web/app/[repo]/tree/[ref]/[[...path]]/page.tsx b/web/app/[repo]/tree/[ref]/[[...path]]/page.tsx
new file mode 100644
index 0000000..3db9605
--- /dev/null
+++ b/web/app/[repo]/tree/[ref]/[[...path]]/page.tsx
@@ -0,0 +1,23 @@
+import { notFound } from "next/navigation";
+import { getTree } from "@/lib/api";
+import { dec, decPath } from "@/lib/params";
+import TreeTable from "@/components/TreeTable";
+import Crumbs from "@/components/Crumbs";
+
+type Props = { params: Promise<{ repo: string; ref: string; path?: string[] }> };
+
+export default async function TreePage({ params }: Props) {
+  const p = await params;
+  const repo = dec(p.repo);
+  const ref = dec(p.ref);
+  const path = decPath(p.path);
+  const entries = await getTree(repo, ref, path);
+  if (!entries) notFound();
+
+  return (
+    <div className="space-y-4">
+      <Crumbs repo={repo} refName={ref} path={path} leafIsLink={false} />
+      <TreeTable repo={repo} refName={ref} dir={path} entries={entries} />
+    </div>
+  );
+}
diff --git a/web/app/error.tsx b/web/app/error.tsx
new file mode 100644
index 0000000..fc6a6ac
--- /dev/null
+++ b/web/app/error.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+export default function Error({ reset }: { error: Error; reset: () => void }) {
+  return (
+    <div className="mx-auto max-w-xl border border-line bg-panel px-8 py-10">
+      <p className="font-mono text-sm text-gold">❄ the archive is unreachable</p>
+      <p className="mt-4 text-sm leading-relaxed text-fog">
+        The backend git server didn&apos;t answer. It may be waking up, restarting, or
+        offline — the repositories themselves are safe on the owner&apos;s machine.
+      </p>
+      <button
+        type="button"
+        onClick={reset}
+        className="mt-6 border border-line px-4 py-1.5 font-mono text-xs text-fog transition-colors hover:border-frost/60 hover:text-snow"
+      >
+        try again
+      </button>
+    </div>
+  );
+}
diff --git a/web/app/globals.css b/web/app/globals.css
new file mode 100644
index 0000000..70a4c76
--- /dev/null
+++ b/web/app/globals.css
@@ -0,0 +1,130 @@
+@import "tailwindcss";
+
+@theme inline {
+  --color-night: #0b0c10;
+  --color-abyss: #08090c;
+  --color-panel: #101218;
+  --color-raise: #161923;
+  --color-line: #1f232e;
+  --color-fog: #8a93a3;
+  --color-dim: #5c6472;
+  --color-snow: #dfe3ea;
+  --color-frost: #5eead4;
+  --color-gold: #e8c170;
+  --color-add: #86efac;
+  --color-add-bg: #0d2818;
+  --color-del: #fca5a5;
+  --color-del-bg: #2d1214;
+  --font-body: var(--font-inter), system-ui, sans-serif;
+  --font-mono: var(--font-jbmono), ui-monospace, monospace;
+  --font-display: var(--font-serif), Georgia, serif;
+}
+
+html {
+  background: var(--color-night);
+  color-scheme: dark;
+}
+
+body {
+  background: var(--color-night);
+  color: var(--color-snow);
+  font-family: var(--font-body);
+  -webkit-font-smoothing: antialiased;
+}
+
+::selection {
+  background: color-mix(in srgb, var(--color-frost) 30%, transparent);
+}
+
+/* ——— code blocks ——— */
+
+.shiki {
+  background: transparent !important;
+  counter-reset: ln;
+  font-size: 0.8125rem;
+  line-height: 1.6;
+}
+
+.shiki code {
+  display: block;
+  width: fit-content;
+  min-width: 100%;
+}
+
+.shiki .line::before {
+  counter-increment: ln;
+  content: counter(ln);
+  display: inline-block;
+  width: 3.5ch;
+  margin-right: 2ch;
+  text-align: right;
+  color: var(--color-dim);
+  user-select: none;
+}
+
+/* ——— rendered markdown ——— */
+
+.prose-frost {
+  font-size: 0.9375rem;
+  line-height: 1.7;
+  color: var(--color-snow);
+}
+.prose-frost h1,
+.prose-frost h2,
+.prose-frost h3,
+.prose-frost h4 {
+  font-family: var(--font-display);
+  font-weight: 400;
+  letter-spacing: 0.01em;
+  margin: 1.6em 0 0.5em;
+  color: var(--color-snow);
+}
+.prose-frost h1 { font-size: 1.75rem; margin-top: 0.4em; }
+.prose-frost h2 { font-size: 1.4rem; border-bottom: 1px solid var(--color-line); padding-bottom: 0.3em; }
+.prose-frost h3 { font-size: 1.15rem; }
+.prose-frost p { margin: 0.8em 0; }
+.prose-frost a { color: var(--color-frost); }
+.prose-frost a:hover { text-decoration: underline; }
+.prose-frost code {
+  font-family: var(--font-mono);
+  font-size: 0.85em;
+  background: var(--color-raise);
+  border: 1px solid var(--color-line);
+  padding: 0.1em 0.35em;
+}
+.prose-frost pre {
+  background: var(--color-abyss);
+  border: 1px solid var(--color-line);
+  padding: 0.9rem 1.1rem;
+  overflow-x: auto;
+  margin: 1em 0;
+  font-size: 0.8125rem;
+  line-height: 1.6;
+}
+.prose-frost pre code { background: none; border: 0; padding: 0; font-size: inherit; }
+.prose-frost ul, .prose-frost ol { padding-left: 1.4em; margin: 0.8em 0; }
+.prose-frost ul { list-style: none; }
+.prose-frost ul > li::before {
+  content: "▪";
+  color: var(--color-frost);
+  display: inline-block;
+  width: 1.2em;
+  margin-left: -1.2em;
+  font-size: 0.7em;
+  vertical-align: 0.15em;
+}
+.prose-frost ol { list-style: decimal; }
+.prose-frost li { margin: 0.3em 0; }
+.prose-frost blockquote {
+  border-left: 2px solid var(--color-gold);
+  padding-left: 1em;
+  color: var(--color-fog);
+  margin: 1em 0;
+}
+.prose-frost table { border-collapse: collapse; margin: 1em 0; font-size: 0.875rem; }
+.prose-frost th, .prose-frost td { border: 1px solid var(--color-line); padding: 0.4em 0.8em; text-align: left; }
+.prose-frost th { background: var(--color-raise); font-weight: 600; }
+.prose-frost img { max-width: 100%; border: 1px solid var(--color-line); }
+.prose-frost hr { border: 0; border-top: 1px solid var(--color-line); margin: 1.6em 0; }
+.prose-frost [align="center"] { text-align: center; }
+.prose-frost [align="center"] img { display: inline-block; }
diff --git a/web/app/icon.svg b/web/app/icon.svg
new file mode 100644
index 0000000..9fb64c9
--- /dev/null
+++ b/web/app/icon.svg
@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+  <rect width="32" height="32" fill="#0b0c10"/>
+  <g fill="#5eead4" shape-rendering="crispEdges">
+    <rect x="14" y="4" width="4" height="24"/>
+    <rect x="4" y="14" width="24" height="4"/>
+    <rect x="8" y="8" width="4" height="4"/>
+    <rect x="20" y="8" width="4" height="4"/>
+    <rect x="8" y="20" width="4" height="4"/>
+    <rect x="20" y="20" width="4" height="4"/>
+  </g>
+  <rect x="14" y="14" width="4" height="4" fill="#e8c170" shape-rendering="crispEdges"/>
+</svg>
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
new file mode 100644
index 0000000..b172258
--- /dev/null
+++ b/web/app/layout.tsx
@@ -0,0 +1,46 @@
+import type { Metadata } from "next";
+import { Inter, JetBrains_Mono, Instrument_Serif } from "next/font/google";
+import Link from "next/link";
+import Starfield from "@/components/Starfield";
+import "./globals.css";
+
+const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
+const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jbmono" });
+const serif = Instrument_Serif({
+  subsets: ["latin"],
+  weight: "400",
+  style: ["normal", "italic"],
+  variable: "--font-serif",
+});
+
+export const metadata: Metadata = {
+  title: { default: "frieren", template: "%s · frieren" },
+  description:
+    "A self-hosted archive of one person's code — browse and clone everything, write nothing.",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+  return (
+    <html lang="en" className={`${inter.variable} ${mono.variable} ${serif.variable}`}>
+      <body className="relative min-h-screen">
+        <Starfield />
+        <header className="mx-auto flex max-w-5xl items-baseline justify-between px-6 pb-4 pt-6">
+          <Link href="/" className="font-display text-2xl tracking-wide">
+            <span className="text-frost">❄</span> frieren
+          </Link>
+          <span className="font-mono text-xs text-dim">a personal git archive</span>
+        </header>
+        <main className="mx-auto max-w-5xl px-6 pb-24 pt-6">{children}</main>
+        <footer className="mx-auto max-w-5xl border-t border-line px-6 py-6 font-mono text-xs text-dim">
+          one writer · world readers — served from the owner&apos;s own machine by{" "}
+          <a
+            href="https://github.com/justin06lee/frieren"
+            className="text-fog hover:text-frost"
+          >
+            frieren
+          </a>
+        </footer>
+      </body>
+    </html>
+  );
+}
diff --git a/web/app/not-found.tsx b/web/app/not-found.tsx
new file mode 100644
index 0000000..6740b12
--- /dev/null
+++ b/web/app/not-found.tsx
@@ -0,0 +1,15 @@
+import Link from "next/link";
+
+export default function NotFound() {
+  return (
+    <div className="mx-auto max-w-xl border border-line bg-panel px-8 py-10 text-center">
+      <p className="font-display text-4xl">404</p>
+      <p className="mt-3 text-sm text-fog">
+        Whatever was here, time has taken it.{" "}
+        <Link href="/" className="text-frost hover:underline">
+          Back to the archive.
+        </Link>
+      </p>
+    </div>
+  );
+}
diff --git a/web/app/page.tsx b/web/app/page.tsx
new file mode 100644
index 0000000..dd86680
--- /dev/null
+++ b/web/app/page.tsx
@@ -0,0 +1,63 @@
+import Link from "next/link";
+import { getRepos, BackendOffline, type RepoInfo } from "@/lib/api";
+import { timeAgo } from "@/lib/format";
+import Offline from "@/components/Offline";
+
+// Always render against the live backend — never bake an offline state in at build time.
+export const dynamic = "force-dynamic";
+
+export default async function Home() {
+  let repos: RepoInfo[];
+  try {
+    repos = (await getRepos()) ?? [];
+  } catch (e) {
+    if (e instanceof BackendOffline) return <Offline />;
+    throw e;
+  }
+
+  return (
+    <>
+      <section className="mb-14 mt-6">
+        <h1 className="font-display text-5xl leading-tight">
+          Code that <em className="text-frost">outlives</em> the platforms.
+        </h1>
+        <p className="mt-4 max-w-2xl text-fog">
+          Every repository here lives on hardware its owner controls. Browse anything,
+          clone everything — writing is reserved for one person.
+        </p>
+      </section>
+
+      {repos.length === 0 ? (
+        <p className="border border-line bg-panel px-6 py-10 text-sm text-fog">
+          The archive is empty — the first <code className="text-snow">git push</code> will
+          fill it.
+        </p>
+      ) : (
+        <div className="grid gap-4 sm:grid-cols-2">
+          {repos.map((r) => (
+            <Link
+              key={r.name}
+              href={`/${r.name}`}
+              className="group border border-line bg-panel p-5 transition-colors hover:border-frost/60"
+            >
+              <div className="flex items-baseline justify-between gap-3">
+                <h2 className="font-mono text-base text-snow group-hover:text-frost">
+                  {r.name}
+                </h2>
+                <span className="shrink-0 font-mono text-xs text-dim">
+                  {r.empty ? "empty" : timeAgo(r.lastCommit)}
+                </span>
+              </div>
+              <p className="mt-2 line-clamp-2 min-h-10 text-sm text-fog">
+                {r.description || "no description"}
+              </p>
+              <p className="mt-3 font-mono text-xs text-dim">
+                <span className="text-gold/70">▪</span> {r.default}
+              </p>
+            </Link>
+          ))}
+        </div>
+      )}
+    </>
+  );
+}
diff --git a/web/bun.lock b/web/bun.lock
new file mode 100644
index 0000000..01b37c2
--- /dev/null
+++ b/web/bun.lock
@@ -0,0 +1,497 @@
+{
+  "lockfileVersion": 1,
+  "configVersion": 1,
+  "workspaces": {
+    "": {
+      "name": "web",
+      "dependencies": {
+        "next": "16.3.1",
+        "react": "19.2.8",
+        "react-dom": "19.2.8",
+        "react-markdown": "^10.1.0",
+        "rehype-raw": "^7.0.0",
+        "rehype-sanitize": "^6.0.0",
+        "remark-gfm": "^4.0.1",
+        "shiki": "^4.4.3",
+      },
+      "devDependencies": {
+        "@tailwindcss/postcss": "^4",
+        "@types/node": "^20",
+        "@types/react": "^19",
+        "@types/react-dom": "^19",
+        "tailwindcss": "^4",
+        "typescript": "^5",
+      },
+    },
+  },
+  "trustedDependencies": [
+    "sharp",
+  ],
+  "packages": {
+    "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+
+    "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
+
+    "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
+
+    "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
+
+    "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
+
+    "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
+
+    "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
+
+    "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
+
+    "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
+
+    "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
+
+    "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
+
+    "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
+
+    "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
+
+    "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
+
+    "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
+
+    "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
+
+    "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
+
+    "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
+
+    "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
+
+    "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
+
+    "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
+
+    "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
+
+    "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
+
+    "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
+
+    "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
+
+    "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
+
+    "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
+
+    "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
+
+    "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
+
+    "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+    "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+    "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+    "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+    "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+    "@next/env": ["@next/env@16.3.1", "", {}, "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ=="],
+
+    "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw=="],
+
+    "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw=="],
+
+    "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w=="],
+
+    "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw=="],
+
+    "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg=="],
+
+    "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA=="],
+
+    "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog=="],
+
+    "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw=="],
+
+    "@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="],
+
+    "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="],
+
+    "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="],
+
+    "@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="],
+
+    "@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="],
+
+    "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="],
+
+    "@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="],
+
+    "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
+
+    "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
+
+    "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
+
+    "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
+
+    "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
+
+    "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
+
+    "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
+
+    "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
+
+    "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
+
+    "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
+
+    "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
+
+    "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
+
+    "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
+
+    "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
+
+    "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
+
+    "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
+
+    "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="],
+
+    "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
+
+    "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
+
+    "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
+
+    "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
+
+    "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
+
+    "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
+
+    "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
+
+    "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
+
+    "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
+
+    "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+    "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="],
+
+    "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
+
+    "baseline-browser-mapping": ["baseline-browser-mapping@2.11.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA=="],
+
+    "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
+
+    "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+
+    "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+
+    "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
+
+    "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+
+    "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
+
+    "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
+
+    "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
+
+    "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+    "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+    "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
+
+    "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
+    "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+    "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+
+    "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
+
+    "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+    "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+
+    "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
+
+    "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+
+    "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+    "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
+
+    "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
+
+    "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
+
+    "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
+
+    "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
+
+    "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
+
+    "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
+
+    "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+
+    "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
+
+    "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
+
+    "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
+
+    "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
+
+    "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
+
+    "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+
+    "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+
+    "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
+
+    "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+
+    "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
+
+    "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
+
+    "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
+
+    "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
+
+    "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
+
+    "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
+
+    "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
+
+    "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
+
+    "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
+
+    "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
+
+    "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
+
+    "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
+
+    "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+
+    "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
+
+    "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+    "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
+
+    "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
+
+    "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
+
+    "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
+
+    "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
+
+    "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
+
+    "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
+
+    "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
+
+    "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
+
+    "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
+
+    "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
+
+    "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
+
+    "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
+
+    "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
+
+    "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
+
+    "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+
+    "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
+
+    "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
+
+    "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
+
+    "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
+
+    "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
+
+    "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
+
+    "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
+
+    "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
+
+    "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
+
+    "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
+
+    "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
+
+    "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
+
+    "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
+
+    "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
+
+    "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+
+    "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
+
+    "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
+
+    "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
+
+    "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
+
+    "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
+
+    "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+
+    "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
+
+    "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
+
+    "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
+
+    "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+
+    "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
+
+    "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
+
+    "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+
+    "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+    "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
+
+    "next": ["next@16.3.1", "", { "dependencies": { "@next/env": "16.3.1", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.1", "@next/swc-darwin-x64": "16.3.1", "@next/swc-linux-arm64-gnu": "16.3.1", "@next/swc-linux-arm64-musl": "16.3.1", "@next/swc-linux-x64-gnu": "16.3.1", "@next/swc-linux-x64-musl": "16.3.1", "@next/swc-win32-arm64-msvc": "16.3.1", "@next/swc-win32-x64-msvc": "16.3.1", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA=="],
+
+    "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
+
+    "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
+
+    "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
+
+    "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
+    "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+    "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
+
+    "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
+
+    "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
+
+    "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
+
+    "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
+
+    "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
+
+    "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
+
+    "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
+
+    "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
+
+    "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
+
+    "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
+
+    "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
+
+    "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
+
+    "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
+
+    "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+
+    "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
+
+    "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" } }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
+
+    "shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="],
+
+    "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+    "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
+
+    "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
+
+    "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
+
+    "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
+
+    "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
+
+    "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
+
+    "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
+
+    "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
+
+    "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
+
+    "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+    "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+    "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+    "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
+
+    "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
+
+    "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
+
+    "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
+
+    "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
+
+    "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+
+    "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+
+    "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
+
+    "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+
+    "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
+
+    "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
+
+    "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+    "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+  }
+}
diff --git a/web/components/CloneBox.tsx b/web/components/CloneBox.tsx
new file mode 100644
index 0000000..9ded322
--- /dev/null
+++ b/web/components/CloneBox.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useState } from "react";
+
+export default function CloneBox({ url }: { url: string }) {
+  const [copied, setCopied] = useState(false);
+  const cmd = `git clone ${url}`;
+  return (
+    <button
+      type="button"
+      onClick={() => {
+        navigator.clipboard.writeText(cmd).then(() => {
+          setCopied(true);
+          setTimeout(() => setCopied(false), 1500);
+        });
+      }}
+      title="copy clone command"
+      className="group flex max-w-full items-center gap-3 border border-line bg-panel px-3 py-1.5 text-left font-mono text-xs text-fog transition-colors hover:border-frost/60"
+    >
+      <span className="truncate">{cmd}</span>
+      <span className={copied ? "text-frost" : "text-dim group-hover:text-frost"}>
+        {copied ? "copied" : "copy"}
+      </span>
+    </button>
+  );
+}
diff --git a/web/components/Crumbs.tsx b/web/components/Crumbs.tsx
new file mode 100644
index 0000000..feb8ec8
--- /dev/null
+++ b/web/components/Crumbs.tsx
@@ -0,0 +1,41 @@
+import Link from "next/link";
+
+// Breadcrumb path for tree/blob pages: repo / dir / dir / name
+export default function Crumbs({
+  repo,
+  refName,
+  path,
+  leafIsLink,
+}: {
+  repo: string;
+  refName: string;
+  path: string;
+  leafIsLink: boolean;
+}) {
+  const ref = encodeURIComponent(refName);
+  const parts = path === "" ? [] : path.split("/");
+  return (
+    <p className="font-mono text-sm text-fog">
+      <Link href={`/${repo}/tree/${ref}`} className="text-snow hover:text-frost">
+        {repo}
+      </Link>
+      {parts.map((part, i) => {
+        const sub = parts.slice(0, i + 1).join("/");
+        const last = i === parts.length - 1;
+        return (
+          <span key={sub}>
+            {" / "}
+            {last && !leafIsLink ? (
+              <span className="text-snow">{part}</span>
+            ) : (
+              <Link href={`/${repo}/tree/${ref}/${sub}`} className="hover:text-frost">
+                {part}
+              </Link>
+            )}
+          </span>
+        );
+      })}
+      <span className="ml-3 border border-line px-1.5 py-0.5 text-xs text-frost">{refName}</span>
+    </p>
+  );
+}
diff --git a/web/components/Diff.tsx b/web/components/Diff.tsx
new file mode 100644
index 0000000..dfe9aa3
--- /dev/null
+++ b/web/components/Diff.tsx
@@ -0,0 +1,56 @@
+import { parsePatch } from "@/lib/diff";
+
+const lineStyles = {
+  add: "bg-add-bg text-add",
+  del: "bg-del-bg text-del",
+  ctx: "text-fog",
+  hunk: "bg-raise text-frost/80",
+} as const;
+
+export default function Diff({ patch }: { patch: string }) {
+  const { stat, files } = parsePatch(patch);
+  return (
+    <div className="space-y-6">
+      {stat && (
+        <pre className="overflow-x-auto border border-line bg-panel px-4 py-3 font-mono text-xs leading-relaxed text-fog">
+          {stat}
+        </pre>
+      )}
+      {files.map((f) => (
+        <section key={f.path} className="border border-line bg-panel">
+          <header className="flex items-center gap-3 border-b border-line bg-raise px-4 py-2 font-mono text-xs">
+            <span className="truncate text-snow">{f.path}</span>
+            <span className="ml-auto shrink-0 text-add">+{f.adds}</span>
+            <span className="shrink-0 text-del">−{f.dels}</span>
+          </header>
+          {f.binary ? (
+            <p className="px-4 py-4 font-mono text-xs text-fog">binary file changed</p>
+          ) : (
+            <div className="overflow-x-auto">
+              <table className="w-full border-collapse font-mono text-xs leading-relaxed">
+                <tbody>
+                  {f.lines.map((l, i) => (
+                    <tr key={i} className={lineStyles[l.kind]}>
+                      <td className="w-10 select-none pr-2 text-right align-top text-dim">
+                        {l.old ?? ""}
+                      </td>
+                      <td className="w-10 select-none pr-3 text-right align-top text-dim">
+                        {l.new ?? ""}
+                      </td>
+                      <td className="w-4 select-none text-center align-top opacity-70">
+                        {l.kind === "add" ? "+" : l.kind === "del" ? "−" : ""}
+                      </td>
+                      <td className="whitespace-pre pr-4 align-top">
+                        {l.kind === "hunk" ? l.text : l.text || " "}
+                      </td>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            </div>
+          )}
+        </section>
+      ))}
+    </div>
+  );
+}
diff --git a/web/components/Markdown.tsx b/web/components/Markdown.tsx
new file mode 100644
index 0000000..03a7c12
--- /dev/null
+++ b/web/components/Markdown.tsx
@@ -0,0 +1,43 @@
+import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
+import remarkGfm from "remark-gfm";
+import rehypeRaw from "rehype-raw";
+import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
+import { rawUrl } from "@/lib/api";
+
+// READMEs often embed HTML (centered headers, <img>, <br>). Render it, but
+// sanitized — repository content must never script against the site.
+const schema = {
+  ...defaultSchema,
+  attributes: {
+    ...defaultSchema.attributes,
+    "*": [...(defaultSchema.attributes?.["*"] ?? []), "align", "width", "height"],
+  },
+};
+
+// Renders a repository README. Relative image/link targets are rewritten to
+// the backend's raw endpoint so screenshots in READMEs just work.
+export default function Markdown({
+  repo,
+  refName,
+  source,
+}: {
+  repo: string;
+  refName: string;
+  source: string;
+}) {
+  const transform = (url: string) => {
+    if (/^(https?:|mailto:|#|data:)/i.test(url)) return defaultUrlTransform(url);
+    return rawUrl(repo, refName, url.replace(/^\.\//, ""));
+  };
+  return (
+    <div className="prose-frost">
+      <ReactMarkdown
+        remarkPlugins={[remarkGfm]}
+        rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]}
+        urlTransform={transform}
+      >
+        {source}
+      </ReactMarkdown>
+    </div>
+  );
+}
diff --git a/web/components/Offline.tsx b/web/components/Offline.tsx
new file mode 100644
index 0000000..6acecce
--- /dev/null
+++ b/web/components/Offline.tsx
@@ -0,0 +1,31 @@
+import { apiBase } from "@/lib/api";
+
+// Shown while the backend git server isn't reachable yet.
+export default function Offline() {
+  const base = apiBase();
+  return (
+    <div className="mx-auto max-w-xl border border-line bg-panel px-8 py-10">
+      <p className="font-mono text-sm text-gold">❄ the archive is unreachable</p>
+      <p className="mt-4 text-sm leading-relaxed text-fog">
+        {base ? (
+          <>
+            This site is configured to read from <code className="text-snow">{base}</code>,
+            but that server didn&apos;t answer. If you run this frieren, check that the
+            backend is up and reachable from the internet.
+          </>
+        ) : (
+          <>
+            No backend is configured yet. Set the <code className="text-snow">FRIEREN_API_URL</code>{" "}
+            environment variable on this deployment to the public URL of your frieren git
+            server (for example <code className="text-snow">https://git.example.com</code>),
+            then redeploy.
+          </>
+        )}
+      </p>
+      <p className="mt-4 text-sm leading-relaxed text-fog">
+        Everything here is read-only — the repositories live on the owner&apos;s own
+        machine, and this page is just the window into them.
+      </p>
+    </div>
+  );
+}
diff --git a/web/components/RepoNav.tsx b/web/components/RepoNav.tsx
new file mode 100644
index 0000000..22784a8
--- /dev/null
+++ b/web/components/RepoNav.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+export default function RepoNav({
+  repo,
+  defaultBranch,
+}: {
+  repo: string;
+  defaultBranch: string;
+}) {
+  const pathname = usePathname();
+  const base = `/${repo}`;
+  const tabs = [
+    { label: "overview", href: base, active: pathname === base },
+    {
+      label: "files",
+      href: `${base}/tree/${encodeURIComponent(defaultBranch)}`,
+      active: pathname.startsWith(`${base}/tree/`) || pathname.startsWith(`${base}/blob/`),
+    },
+    {
+      label: "commits",
+      href: `${base}/commits`,
+      active: pathname.startsWith(`${base}/commit`),
+    },
+    { label: "refs", href: `${base}/refs`, active: pathname === `${base}/refs` },
+  ];
+  return (
+    <nav className="flex gap-6 border-b border-line font-mono text-sm">
+      {tabs.map((t) => (
+        <Link
+          key={t.label}
+          href={t.href}
+          className={
+            t.active
+              ? "-mb-px border-b border-frost pb-2 text-snow"
+              : "pb-2 text-fog transition-colors hover:text-snow"
+          }
+        >
+          {t.label}
+        </Link>
+      ))}
+    </nav>
+  );
+}
diff --git a/web/components/Starfield.tsx b/web/components/Starfield.tsx
new file mode 100644
index 0000000..276e7f1
--- /dev/null
+++ b/web/components/Starfield.tsx
@@ -0,0 +1,34 @@
+// The banner's commit-graph constellation, faint, behind the page header.
+export default function Starfield() {
+  return (
+    <svg
+      aria-hidden
+      className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-72 w-full"
+      viewBox="0 0 1200 288"
+      preserveAspectRatio="xMidYMin slice"
+    >
+      <g stroke="#1d2430" strokeWidth="1.5">
+        <line x1="705" y1="165" x2="785" y2="145" />
+        <line x1="785" y1="145" x2="865" y2="125" />
+        <line x1="865" y1="125" x2="945" y2="105" />
+        <line x1="785" y1="145" x2="845" y2="195" />
+        <line x1="845" y1="195" x2="925" y2="175" />
+        <line x1="925" y1="175" x2="945" y2="105" />
+      </g>
+      <g shapeRendering="crispEdges">
+        <rect x="702" y="162" width="7" height="7" fill="#e8c170" opacity="0.55" />
+        <rect x="782" y="142" width="7" height="7" fill="#e8c170" opacity="0.55" />
+        <rect x="862" y="122" width="7" height="7" fill="#e8c170" opacity="0.55" />
+        <rect x="942" y="102" width="7" height="7" fill="#e8c170" opacity="0.55" />
+        <rect x="842" y="192" width="7" height="7" fill="#5eead4" opacity="0.55" />
+        <rect x="922" y="172" width="7" height="7" fill="#5eead4" opacity="0.55" />
+        {[
+          [80, 60], [170, 130], [260, 40], [340, 100], [440, 60], [520, 150],
+          [600, 50], [1020, 70], [1100, 150], [1160, 60], [90, 200], [420, 210],
+        ].map(([x, y]) => (
+          <rect key={`${x}-${y}`} x={x} y={y} width="3" height="3" fill="#2b3342" />
+        ))}
+      </g>
+    </svg>
+  );
+}
diff --git a/web/components/TreeTable.tsx b/web/components/TreeTable.tsx
new file mode 100644
index 0000000..f54f32b
--- /dev/null
+++ b/web/components/TreeTable.tsx
@@ -0,0 +1,63 @@
+import Link from "next/link";
+import type { TreeEntry } from "@/lib/api";
+import { byteSize } from "@/lib/format";
+
+function DirIcon() {
+  return (
+    <svg viewBox="0 0 16 16" className="h-3.5 w-3.5 fill-gold/80" aria-hidden shapeRendering="crispEdges">
+      <path d="M1 3h5l1 2h8v8H1z" />
+    </svg>
+  );
+}
+
+function FileIcon() {
+  return (
+    <svg viewBox="0 0 16 16" className="h-3.5 w-3.5 fill-dim" aria-hidden shapeRendering="crispEdges">
+      <path d="M3 1h7l3 3v11H3z" />
+    </svg>
+  );
+}
+
+export default function TreeTable({
+  repo,
+  refName,
+  dir,
+  entries,
+}: {
+  repo: string;
+  refName: string;
+  dir: string;
+  entries: TreeEntry[];
+}) {
+  const ref = encodeURIComponent(refName);
+  const base = dir ? `${dir}/` : "";
+  return (
+    <div className="border border-line bg-panel">
+      {entries.map((e) => {
+        const href = `/${repo}/${e.type === "tree" ? "tree" : "blob"}/${ref}/${base}${e.name}`;
+        return (
+          <div
+            key={e.name}
+            className="flex items-center gap-3 border-b border-line px-4 py-2 text-sm last:border-b-0 hover:bg-raise"
+          >
+            {e.type === "tree" ? <DirIcon /> : <FileIcon />}
+            {e.type === "commit" ? (
+              <span className="font-mono text-fog">{e.name} @ {e.hash.slice(0, 7)}</span>
+            ) : (
+              <Link href={href} className="font-mono text-snow hover:text-frost">
+                {e.name}
+                {e.type === "tree" ? "/" : ""}
+              </Link>
+            )}
+            <span className="ml-auto font-mono text-xs text-dim">
+              {e.type === "blob" ? byteSize(e.size) : ""}
+            </span>
+          </div>
+        );
+      })}
+      {entries.length === 0 && (
+        <p className="px-4 py-6 text-sm text-fog">empty directory</p>
+      )}
+    </div>
+  );
+}
diff --git a/web/lib/api.ts b/web/lib/api.ts
new file mode 100644
index 0000000..ba6fb4e
--- /dev/null
+++ b/web/lib/api.ts
@@ -0,0 +1,96 @@
+// Server-side client for the frieren backend's read-only JSON API.
+
+export type RepoInfo = {
+  name: string;
+  description: string;
+  default: string;
+  lastCommit: string;
+  empty: boolean;
+};
+
+export type TreeEntry = {
+  mode: string;
+  type: "blob" | "tree" | "commit";
+  hash: string;
+  size: number;
+  name: string;
+};
+
+export type Blob = {
+  path: string;
+  size: number;
+  binary: boolean;
+  truncated: boolean;
+  content: string;
+};
+
+export type Readme = { name: string; content: string };
+
+export type Commit = {
+  hash: string;
+  short: string;
+  author: string;
+  when: string;
+  subject: string;
+};
+
+export type CommitDetail = Commit & { patch: string; truncated: boolean };
+
+export type Refs = {
+  branches: { name: string; short: string; when: string; subject: string }[];
+  tags: { name: string; short: string; when: string; subject: string }[];
+};
+
+export function apiBase(): string | null {
+  const base = process.env.FRIEREN_API_URL;
+  return base ? base.replace(/\/+$/, "") : null;
+}
+
+// Where git users point their clients — defaults to the API host.
+export function cloneUrl(repo: string): string {
+  const base = process.env.FRIEREN_CLONE_URL?.replace(/\/+$/, "") ?? apiBase();
+  return `${base ?? "https://your-frieren-server"}/${repo}.git`;
+}
+
+export function rawUrl(repo: string, ref: string, path: string): string {
+  const segs = path.split("/").map(encodeURIComponent).join("/");
+  return `${apiBase()}/${repo}/raw/${encodeURIComponent(ref)}/${segs}`;
+}
+
+export class BackendOffline extends Error {
+  constructor() {
+    super("frieren backend unreachable");
+  }
+}
+
+// api fetches a path, returning null on 404/400 and throwing BackendOffline
+// when the backend is missing or unreachable.
+async function api<T>(path: string): Promise<T | null> {
+  const base = apiBase();
+  if (!base) throw new BackendOffline();
+  let res: Response;
+  try {
+    res = await fetch(`${base}/api${path}`, { next: { revalidate: 30 } });
+  } catch {
+    throw new BackendOffline();
+  }
+  if (res.status === 404 || res.status === 400) return null;
+  if (!res.ok) throw new BackendOffline();
+  return (await res.json()) as T;
+}
+
+const q = encodeURIComponent;
+
+export const getRepos = () => api<RepoInfo[]>("/repos");
+export const getRepo = (repo: string) => api<RepoInfo>(`/repos/${q(repo)}`);
+export const getTree = (repo: string, ref: string, path: string) =>
+  api<TreeEntry[]>(`/repos/${q(repo)}/tree?ref=${q(ref)}&path=${q(path)}`);
+export const getBlob = (repo: string, ref: string, path: string) =>
+  api<Blob>(`/repos/${q(repo)}/blob?ref=${q(ref)}&path=${q(path)}`);
+export const getReadme = (repo: string, ref: string) =>
+  api<Readme>(`/repos/${q(repo)}/readme?ref=${q(ref)}`);
+export const getCommits = (repo: string, ref: string, n = 100) =>
+  api<Commit[]>(`/repos/${q(repo)}/commits?ref=${q(ref)}&n=${n}`);
+export const getCommit = (repo: string, hash: string) =>
+  api<CommitDetail>(`/repos/${q(repo)}/commit/${q(hash)}`);
+export const getRefs = (repo: string) => api<Refs>(`/repos/${q(repo)}/refs`);
diff --git a/web/lib/diff.ts b/web/lib/diff.ts
new file mode 100644
index 0000000..25acbed
--- /dev/null
+++ b/web/lib/diff.ts
@@ -0,0 +1,71 @@
+// Parses the `git show --stat --patch` text the backend returns into a
+// structure the diff view can render with per-side line numbers.
+
+export type DiffLine = {
+  kind: "add" | "del" | "ctx" | "hunk";
+  old: number | null;
+  new: number | null;
+  text: string;
+};
+
+export type DiffFile = {
+  path: string;
+  adds: number;
+  dels: number;
+  binary: boolean;
+  lines: DiffLine[];
+};
+
+export type ParsedPatch = { stat: string; files: DiffFile[] };
+
+export function parsePatch(patch: string): ParsedPatch {
+  const idx = patch.indexOf("diff --git ");
+  const stat = (idx === -1 ? patch : patch.slice(0, idx)).trim();
+  const body = idx === -1 ? "" : patch.slice(idx);
+
+  const files: DiffFile[] = [];
+  let file: DiffFile | null = null;
+  let oldN = 0;
+  let newN = 0;
+
+  for (const line of body.split("\n")) {
+    if (line.startsWith("diff --git ")) {
+      // `diff --git a/path b/path` — take the b/ side.
+      const m = line.match(/ b\/(.*)$/);
+      file = { path: m ? m[1] : line, adds: 0, dels: 0, binary: false, lines: [] };
+      files.push(file);
+      continue;
+    }
+    if (!file) continue;
+    if (line.startsWith("Binary files ")) {
+      file.binary = true;
+      continue;
+    }
+    const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
+    if (hunk) {
+      oldN = parseInt(hunk[1], 10);
+      newN = parseInt(hunk[2], 10);
+      file.lines.push({ kind: "hunk", old: null, new: null, text: line });
+      continue;
+    }
+    if (
+      line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ ") ||
+      line.startsWith("new file") || line.startsWith("deleted file") ||
+      line.startsWith("old mode") || line.startsWith("new mode") ||
+      line.startsWith("similarity") || line.startsWith("rename ") ||
+      line.startsWith("\\ No newline")
+    ) {
+      continue;
+    }
+    if (line.startsWith("+")) {
+      file.adds++;
+      file.lines.push({ kind: "add", old: null, new: newN++, text: line.slice(1) });
+    } else if (line.startsWith("-")) {
+      file.dels++;
+      file.lines.push({ kind: "del", old: oldN++, new: null, text: line.slice(1) });
+    } else {
+      file.lines.push({ kind: "ctx", old: oldN++, new: newN++, text: line.slice(1) });
+    }
+  }
+  return { stat, files };
+}
diff --git a/web/lib/format.ts b/web/lib/format.ts
new file mode 100644
index 0000000..8df0a7d
--- /dev/null
+++ b/web/lib/format.ts
@@ -0,0 +1,31 @@
+export function timeAgo(iso: string): string {
+  const t = new Date(iso).getTime();
+  if (!t || t <= 0) return "";
+  const s = (Date.now() - t) / 1000;
+  if (s < 60) return "just now";
+  if (s < 3600) return `${Math.floor(s / 60)}m ago`;
+  if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
+  if (s < 30 * 86400) return `${Math.floor(s / 86400)}d ago`;
+  return new Date(iso).toLocaleDateString("en-US", {
+    year: "numeric",
+    month: "short",
+    day: "numeric",
+  });
+}
+
+export function byteSize(n: number): string {
+  if (n < 0) return "";
+  if (n < 1024) return `${n} B`;
+  if (n < 1 << 20) return `${(n / 1024).toFixed(1)} KB`;
+  return `${(n / (1 << 20)).toFixed(1)} MB`;
+}
+
+export function fullDate(iso: string): string {
+  return new Date(iso).toLocaleString("en-US", {
+    year: "numeric",
+    month: "short",
+    day: "numeric",
+    hour: "2-digit",
+    minute: "2-digit",
+  });
+}
diff --git a/web/lib/params.ts b/web/lib/params.ts
new file mode 100644
index 0000000..8241399
--- /dev/null
+++ b/web/lib/params.ts
@@ -0,0 +1,13 @@
+// Next.js delivers dynamic segments percent-encoded in some cases; decode
+// defensively so refs like "feat%2Fauth" become "feat/auth".
+export function dec(s: string): string {
+  try {
+    return decodeURIComponent(s);
+  } catch {
+    return s;
+  }
+}
+
+export function decPath(segs: string[] | undefined): string {
+  return (segs ?? []).map(dec).join("/");
+}
diff --git a/web/lib/shiki.ts b/web/lib/shiki.ts
new file mode 100644
index 0000000..11a75e3
--- /dev/null
+++ b/web/lib/shiki.ts
@@ -0,0 +1,46 @@
+import { createHighlighter, type Highlighter } from "shiki";
+
+const LANGS = [
+  "typescript", "tsx", "javascript", "jsx", "json", "go", "rust", "python",
+  "c", "cpp", "css", "html", "yaml", "toml", "markdown", "bash", "sql",
+  "java", "swift", "kotlin", "ruby", "php", "docker", "make", "diff",
+];
+
+const EXT_TO_LANG: Record<string, string> = {
+  ts: "typescript", mts: "typescript", cts: "typescript", tsx: "tsx",
+  js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx",
+  json: "json", go: "go", rs: "rust", py: "python",
+  c: "c", h: "c", cpp: "cpp", cc: "cpp", hpp: "cpp",
+  css: "css", html: "html", htm: "html",
+  yml: "yaml", yaml: "yaml", toml: "toml", md: "markdown",
+  sh: "bash", bash: "bash", zsh: "bash", sql: "sql",
+  java: "java", swift: "swift", kt: "kotlin", rb: "ruby", php: "php",
+  dockerfile: "docker", patch: "diff", diff: "diff",
+};
+
+let highlighter: Promise<Highlighter> | null = null;
+
+function getHighlighter(): Promise<Highlighter> {
+  highlighter ??= createHighlighter({ themes: ["vitesse-dark"], langs: LANGS });
+  return highlighter;
+}
+
+export function langForFile(name: string): string | null {
+  const base = name.toLowerCase().split("/").pop() ?? "";
+  if (base === "makefile") return "make";
+  if (base === "dockerfile") return "docker";
+  const ext = base.includes(".") ? base.split(".").pop()! : "";
+  return EXT_TO_LANG[ext] ?? null;
+}
+
+// highlight returns shiki HTML for known languages, null otherwise
+// (the caller renders a plain <pre> instead).
+export async function highlight(code: string, lang: string | null): Promise<string | null> {
+  if (!lang) return null;
+  try {
+    const hl = await getHighlighter();
+    return hl.codeToHtml(code, { lang, theme: "vitesse-dark" });
+  } catch {
+    return null;
+  }
+}
diff --git a/web/next.config.ts b/web/next.config.ts
new file mode 100644
index 0000000..e9ffa30
--- /dev/null
+++ b/web/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  /* config options here */
+};
+
+export default nextConfig;
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..4c96536
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,37 @@
+{
+  "name": "web",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "next dev",
+    "build": "next build",
+    "start": "next start"
+  },
+  "dependencies": {
+    "next": "16.3.1",
+    "react": "19.2.8",
+    "react-dom": "19.2.8",
+    "react-markdown": "^10.1.0",
+    "rehype-raw": "^7.0.0",
+    "rehype-sanitize": "^6.0.0",
+    "remark-gfm": "^4.0.1",
+    "shiki": "^4.4.3"
+  },
+  "devDependencies": {
+    "@tailwindcss/postcss": "^4",
+    "@types/node": "^20",
+    "@types/react": "^19",
+    "@types/react-dom": "^19",
+    "tailwindcss": "^4",
+    "typescript": "^5"
+  },
+  "packageManager": "bun@1.3.14",
+  "ignoreScripts": [
+    "sharp",
+    "unrs-resolver"
+  ],
+  "trustedDependencies": [
+    "sharp",
+    "unrs-resolver"
+  ]
+}
diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs
new file mode 100644
index 0000000..61e3684
--- /dev/null
+++ b/web/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+  plugins: {
+    "@tailwindcss/postcss": {},
+  },
+};
+
+export default config;
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..3a13f90
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,34 @@
+{
+  "compilerOptions": {
+    "target": "ES2017",
+    "lib": ["dom", "dom.iterable", "esnext"],
+    "allowJs": true,
+    "skipLibCheck": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "react-jsx",
+    "incremental": true,
+    "plugins": [
+      {
+        "name": "next"
+      }
+    ],
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": [
+    "next-env.d.ts",
+    "**/*.ts",
+    "**/*.tsx",
+    ".next/types/**/*.ts",
+    ".next/dev/types/**/*.ts",
+    "**/*.mts"
+  ],
+  "exclude": ["node_modules"]
+}