diff --git a/.gitignore b/.gitignore index 0688952..2db3342 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ yarn-error.log* .env.test.local .env.production.local +# local-dev projectpages.config with real passphrases — sibling of .env.local +projectpages.config.local + # vercel .vercel diff --git a/README.md b/README.md index 1979a4f..ed14d45 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,22 @@ # Project Pages -A Next.js portal that turns a private GitHub repository into a clean, branded documentation site. Content is fetched live via the GitHub API. Access is controlled by passphrases — each passphrase maps to a Git branch, so different audiences see different content from the same repository. +A Next.js portal that turns a private GitHub repository into a clean, branded documentation site. Content is fetched live via the GitHub API. Access is controlled by passphrases — each user group can be granted access to one or more Git branches, so different audiences see different content from the same repository. --- ## How it works -1. You define Git branches in your docs repo (e.g. `master`, `client`), one per audience. -2. Each branch gets a passphrase in `projectpages.config`. -3. A visitor enters their passphrase → the app resolves the matching branch → that branch's content is shown for the lifetime of their session. +1. You define Git branches in your docs repo (e.g. `master`, `client`), one per audience — or set `discoverBranches: true` and every branch is auto-exposed. +2. Each user group gets a passphrase. Passphrases can live in `projectpages.config`, or (recommended) in an environment variable named `PROJECTPAGES_PASSPHRASE_` so no secret ever ships in the docs repo. +3. A visitor enters their passphrase → the app resolves the user group → that group's accessible branches are exposed in a searchable top-nav switcher → the first branch's content is shown for the session. 4. Push to any branch → GitHub webhook fires → Vercel rebuilds → content is fresh. ``` docs-repo (GitHub) - ├── master ← full internal content - ├── client ← curated for the client - └── projectpages.config ← declares branches, passphrases, file filters + ├── master ← full internal content + ├── client ← curated for the client + └── projectpages.config ← user groups, branches, file filters + (passphrases can live in env instead) Project Pages (Vercel) └── reads config → authenticates → serves the right branch per session @@ -24,7 +25,16 @@ Project Pages (Vercel) --- -## Quick start +## Features + +- **Editorial UI** — warm cream paper, near-black ink, ochre accent. Fraunces (variable serif) + DM Sans + JetBrains Mono loaded via `next/font`. Split editorial sign-in cover, ink-on-paper top-nav masthead, sidebar that auto-expands and highlights the currently-viewed file. +- **Frontmatter rendering** — YAML frontmatter is parsed and rendered as an "At a glance" block above the content: serif title, italic description, metadata grid, tag chips, and full-width one-per-line sections for long arrays like `related` and `applies_to`. A duplicate `# Title` in the body is stripped when frontmatter carries the same title. +- **Filterable branch switcher** — search box with match highlighting, current branch pinned to the top, Enter selects the first match, Escape clears. Pairs with `discoverBranches: true` so the switcher can auto-list every branch on the repo. +- **Mermaid with zoom + colour** — fullscreen overlay via `createPortal` with wheel-zoom, drag-pan, HUD, and Escape-to-close. Participants in sequence diagrams and nodes in flowcharts are auto-cycled through a distinct colour palette so multi-actor diagrams read at a glance. + +--- + +## Quick start (production) ```bash cp .env.local.example .env.local @@ -41,6 +51,57 @@ Then add a `projectpages.config` to your docs repository — copy [`projectpages --- +## Quick start (local, no config in the docs repository) + +For local development you can skip the "config lives in the docs repo" round-trip entirely — point the app at a config file on disk and provide the passphrase via env: + +```bash +cp .env.local.example .env.local +# add to .env.local: +# DOCS_REPO=/ +# GITHUB_TOKEN= +# NEXTAUTH_SECRET=$(openssl rand -base64 32) +# NEXTAUTH_URL=http://localhost:3000 +# PROJECTPAGES_LOCAL_CONFIG=/absolute/path/to/projectpages.config.local +# PROJECTPAGES_PASSPHRASE_VAIMO=some-shared-secret + +cp projectpages.config.example projectpages.config.local +# edit projectpages.config.local — leave the user group's passphrase +# empty (the env var wins), and set `discoverBranches: true` if you +# want every branch of your docs repo to appear in the switcher. + +npm install +npm run dev +``` + +Content (file tree + Markdown bodies) still comes from GitHub via the API — only the config lookup is short-circuited. That means the docs repo stays clean (no `projectpages.config` committed) but you still need commits pushed to see them in the app. + +`projectpages.config.local` is git-ignored in this repo (sibling of the local env file convention). Never commit it — it may hold real passphrases. + +### Local-development environment variables + +The full production list lives in [Deployment → Environment variables](./docs/deployment.md#environment-variables). These are the local-development toggles introduced alongside them: + +| Variable | Purpose | +|---|---| +| `PROJECTPAGES_LOCAL_CONFIG` | Absolute path to a `projectpages.config`-shaped YAML file. When set, the app reads config from disk instead of the GitHub API — nothing needs to be committed to the docs repository. | +| `PROJECTPAGES_PASSPHRASE_` | Per-user-group passphrase override. Group name is upper-cased and non-alphanumerics become underscores — e.g. `vaimo` → `PROJECTPAGES_PASSPHRASE_VAIMO`, `external-partner` → `PROJECTPAGES_PASSPHRASE_EXTERNAL_PARTNER`. Wins over the config file when non-empty. Recommended so real secrets never live in the docs repo. | +| `DEV_AUTH_BYPASS` | Set to `1` to skip the passphrase check entirely and log in as the first user group. Development only — never set in production. | + +### Auto-discover branches + +Add a top-level flag to your `projectpages.config` (or `projectpages.config.local`): + +```yaml +discoverBranches: true +``` + +When set, the config loader calls the GitHub `listBranches` API for `DOCS_REPO` and merges every branch into the branch list. Explicit `branches:` entries stay in place and act as templates for permissions/comments/chat; discovered branches inherit those settings from the first explicit entry. Fails soft — if the API call errors, the app falls back to the declared list. + +Combined with the search-enabled top-nav switcher, this makes it easy to work across a repo with dozens of feature/chore branches without hand-listing each one. + +--- + ## Documentation | Topic | Description | diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index c0e92fc..e74c0df 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -1,11 +1,11 @@ "use client"; import { Suspense, useState } from "react"; +import Image from "next/image"; import { signIn } from "next-auth/react"; const ENABLE_GOOGLE = process.env.NEXT_PUBLIC_ENABLE_GOOGLE_LOGIN === "true" || process.env.NEXT_PUBLIC_ENABLE_GOOGLE_LOGIN === "1"; import { useRouter, useSearchParams } from "next/navigation"; -import Image from "next/image"; function SignInForm() { const [passphrase, setPassphrase] = useState(""); @@ -16,74 +16,187 @@ function SignInForm() { const callbackUrl = searchParams.get("callbackUrl") ?? "/"; async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); setError(""); setLoading(true); - - const result = await signIn("credentials", { - passphrase, - redirect: false, - }); - + const result = await signIn("credentials", { passphrase, redirect: false }); setLoading(false); - - if (result?.error) { - setError("Incorrect passphrase. Please try again."); - } else { - router.push(callbackUrl); - } + if (result?.error) setError("Incorrect passphrase. Please try again."); + else router.push(callbackUrl); } return (
-
-
+ {/* Decorative rule */} +
+
+ +
Vaimo + + + Project Pages + +
+ +

- Enter the passphrase to access this space. + Volume 01 — Documentation +

+

+ A quiet
+ reading{" "} + room
+ for the work. +

+

+ Specs, feature overviews, and per-repository technical slices — kept legible, kept together.

-
-
+
+ Est. Vaimo + —— § —— + Internal Docs +
+ + + {/* Right: form */} +
+
+

Access

+

+ Enter the passphrase. +

+

+ Access is by shared secret. Ask your team lead if you don't have one. +

+ +
- {error && ( -

- {error} -

- )} - - - + {error && ( +

+ {error} +

+ )} - {ENABLE_GOOGLE && ( -
-
- )} -
+ + + {ENABLE_GOOGLE && ( + <> +
+
+ or +
+
+ + + )} +
+ + +
); } diff --git a/app/globals.css b/app/globals.css index 6974a2f..b8bdcd0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,22 +1,42 @@ @import "tailwindcss"; :root { - /* Vaimo brand colour tokens */ - --color-grey-900: #1a1a1a; - --color-grey-700: #404040; - --color-grey-500: #808080; - --color-grey-300: #c8c8c8; - --color-grey-100: #f2f2f2; - --color-yellow: #f5c400; - --color-white: #ffffff; - - /* Chat — user-prompt bubble: subtly darker than --color-grey-100 page background */ - --color-chat-user-bg: #e5e5e5; + /* ── Palette — editorial, byredo × vaimo ───────────────────────────────── + Warm cream paper, near-black ink, a hairline stone rule, and a single + ochre-yellow accent used sparingly. Legacy grey-* / yellow tokens are + preserved (some components reference them directly). */ + --color-paper: #f7f4ec; /* warm off-white, page bg */ + --color-paper-alt: #efeadb; /* deeper cream */ + --color-card: #fdfbf6; /* card / prose ground */ + --color-ink-90: #0f0e0b; /* near black */ + --color-ink-70: #3a362e; /* body copy */ + --color-ink-40: #857e6d; /* muted */ + --color-rule: #d6cfba; /* hairline stone */ + --color-rule-soft: #e7e1cf; + --color-accent: #f4b301; /* ochre yellow, brand */ + --color-accent-ink: #7a5300; /* legible on cream */ + --color-accent-tint:#fff1c2; + --color-ink-invert: #fbf7ec; + + /* ── Legacy compatibility tokens ─────────────────────────────────────── */ + --color-grey-900: var(--color-ink-90); + --color-grey-700: var(--color-ink-70); + --color-grey-500: var(--color-ink-40); + --color-grey-300: var(--color-rule); + --color-grey-100: var(--color-paper-alt); + --color-yellow: var(--color-accent); + --color-white: var(--color-card); + --color-chat-user-bg: var(--color-paper-alt); /* Layout */ --sidebar-width: 260px; - --content-max-width: 800px; - --nav-height: 56px; + --content-max-width: 780px; + --nav-height: 64px; + + /* Type stacks — next/font sets the variables in */ + --font-serif: "Fraunces", ui-serif, Georgia, "Times New Roman", serif; + --font-sans: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", "Fira Mono", monospace; } *, @@ -33,18 +53,31 @@ body { } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, - Arial, sans-serif; + font-family: var(--font-sans); font-size: 16px; line-height: 1.6; - color: var(--color-grey-900); - background: var(--color-grey-100); + color: var(--color-ink-90); + background: var(--color-paper); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + /* Faint paper grain — 1px noise overlay, sits behind everything */ + background-image: + radial-gradient(circle at 20% 10%, rgba(122, 83, 0, 0.03), transparent 40%), + radial-gradient(circle at 80% 80%, rgba(15, 14, 11, 0.025), transparent 45%); } +/* Selection */ +::selection { background: var(--color-accent); color: var(--color-ink-90); } + /* ── Prose (rendered markdown) ───────────────────────────────────────────── */ .prose { - max-width: var(--content-max-width); - color: var(--color-grey-900); + max-width: none; + color: var(--color-ink-70); + font-family: var(--font-sans); + font-size: 1rem; + line-height: 1.75; + font-feature-settings: "kern", "liga", "onum"; } .prose h1, @@ -53,99 +86,203 @@ body { .prose h4, .prose h5, .prose h6 { - font-weight: 600; - color: var(--color-grey-900); - margin-top: 2rem; - margin-bottom: 0.75rem; - line-height: 1.3; + font-family: var(--font-serif); + color: var(--color-ink-90); + font-weight: 500; + line-height: 1.15; + letter-spacing: -0.015em; + margin-top: 2.5rem; + margin-bottom: 0.9rem; scroll-margin-top: calc(var(--nav-height) + 1.5rem); + font-variation-settings: "opsz" 72, "SOFT" 20; } -.prose h1 { font-size: 1.875rem; } -.prose h2 { font-size: 1.5rem; border-bottom: 1px solid var(--color-grey-300); padding-bottom: 0.4rem; } -.prose h3 { font-size: 1.25rem; } +.prose h1 { + font-size: clamp(2rem, 3.4vw, 2.75rem); + margin-top: 0; + letter-spacing: -0.022em; + font-variation-settings: "opsz" 144, "SOFT" 30; +} +.prose h2 { + font-size: 1.75rem; + border-bottom: 1px solid var(--color-rule); + padding-bottom: 0.5rem; + font-variation-settings: "opsz" 72, "SOFT" 25; +} +.prose h3 { + font-size: 1.35rem; + font-variation-settings: "opsz" 36, "SOFT" 15; +} +.prose h4 { + font-size: 1.1rem; + font-weight: 600; + font-family: var(--font-sans); + letter-spacing: 0; + color: var(--color-ink-90); + text-transform: none; +} .prose p { - margin-bottom: 1rem; + margin: 0 0 1.15rem; } +.prose strong { color: var(--color-ink-90); font-weight: 600; } +.prose em { font-family: var(--font-serif); font-style: italic; font-weight: 400; font-variation-settings: "opsz" 20; } + .prose a { - color: var(--color-grey-900); + color: var(--color-ink-90); text-decoration: underline; + text-decoration-thickness: 1px; text-underline-offset: 3px; + text-decoration-color: var(--color-rule); + transition: text-decoration-color 0.15s ease, color 0.15s ease; } .prose a:hover { - color: var(--color-grey-700); + color: var(--color-accent-ink); + text-decoration-color: var(--color-accent); } .prose code { - font-family: ui-monospace, "Cascadia Code", "Fira Mono", monospace; - font-size: 0.875em; - background: var(--color-grey-100); - border: 1px solid var(--color-grey-300); - border-radius: 3px; - padding: 0.1em 0.35em; + font-family: var(--font-mono); + font-size: 0.85em; + background: var(--color-paper-alt); + border: 1px solid var(--color-rule); + border-radius: 2px; + padding: 0.1em 0.4em; + color: var(--color-ink-90); } .prose pre { - background: var(--color-grey-100); - border: 1px solid var(--color-grey-300); - border-radius: 6px; - padding: 1rem; + background: var(--color-ink-90); + color: var(--color-ink-invert); + border: none; + border-radius: 2px; + padding: 1.25rem 1.5rem; overflow-x: auto; - margin-bottom: 1.25rem; + margin: 1.5rem 0; + font-size: 0.875rem; + line-height: 1.65; + box-shadow: 0 1px 0 rgba(0,0,0,0.15); } .prose pre code { background: none; border: none; padding: 0; - font-size: 0.875rem; + color: inherit; + font-size: inherit; } +/* Rehype-highlight colors, remapped for the dark code block */ +.prose pre .hljs-keyword, +.prose pre .hljs-selector-tag, +.prose pre .hljs-tag { color: #ffcc70; } +.prose pre .hljs-string, +.prose pre .hljs-attr { color: #c4d8a4; } +.prose pre .hljs-number, +.prose pre .hljs-literal { color: #f7a1a1; } +.prose pre .hljs-comment { color: #7a7060; font-style: italic; } +.prose pre .hljs-title, +.prose pre .hljs-name { color: #b0d0ff; } + .prose blockquote { - border-left: 3px solid var(--color-yellow); - margin-left: 0; - padding-left: 1rem; - color: var(--color-grey-700); + border-left: 2px solid var(--color-accent); + margin: 1.5rem 0; + padding: 0.5rem 0 0.5rem 1.25rem; + color: var(--color-ink-70); + font-family: var(--font-serif); + font-style: italic; + font-variation-settings: "opsz" 24; + font-size: 1.05rem; + line-height: 1.5; } +/* ── Lists — restore native markers Tailwind resets away ─────────────────── */ .prose ul, .prose ol { padding-left: 1.5rem; - margin-bottom: 1rem; + margin: 0 0 1.15rem; +} + +/* Tailwind v4 preflight sets `list-style: none` on ul/ol inside @layer base. + Layer-cascade priority *should* let unlayered rules win, but Turbopack's + compiled output here has the base rule beating ours in practice — force it. */ +.prose ul { list-style-position: outside !important; list-style-type: disc !important; } +.prose ol { list-style-position: outside !important; list-style-type: decimal !important; } + +.prose ul ul { list-style-type: circle !important; } +.prose ul ul ul { list-style-type: square !important; } +.prose ol ol { list-style-type: lower-alpha !important; } +.prose ol ol ol { list-style-type: lower-roman !important; } + +.prose li { margin: 0.25rem 0; } + +.prose li::marker { + color: var(--color-accent-ink); + font-weight: 600; +} + +.prose ul.contains-task-list, +.prose ul.contains-task-list ul { + list-style: none; + padding-left: 1.25rem; } -.prose li { - margin-bottom: 0.25rem; +.prose li > input[type="checkbox"] { + margin-right: 0.5rem; + transform: translateY(1px); + accent-color: var(--color-accent); } +.prose hr { + border: none; + border-top: 1px solid var(--color-rule); + margin: 2.5rem 0; +} + +/* Tables — editorial ruled */ .prose table { width: 100%; border-collapse: collapse; font-size: 0.9375rem; - margin-bottom: 1.25rem; + margin: 1.5rem 0; + border-top: 2px solid var(--color-ink-90); + border-bottom: 1px solid var(--color-ink-90); } -.prose th { +.prose thead th { text-align: left; + font-family: var(--font-sans); font-weight: 600; - padding: 0.5rem 0.75rem; - border-bottom: 2px solid var(--color-grey-300); - background: var(--color-grey-100); + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 0.75rem 0.85rem; + border-bottom: 1px solid var(--color-ink-90); + background: transparent; + color: var(--color-ink-90); } .prose td { - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--color-grey-300); + padding: 0.7rem 0.85rem; + border-bottom: 1px solid var(--color-rule); + vertical-align: top; } +.prose tbody tr:last-child td { border-bottom: none; } + .prose img { max-width: 100%; - border-radius: 4px; + border-radius: 2px; + border: 1px solid var(--color-rule); } +/* Mermaid inner SVGs — keep them readable inside frame */ +.mermaid-frame svg { max-width: 100%; height: auto; } +.mermaid-zoom-svg { width: min(88vw, 1400px); } +.mermaid-zoom-svg svg { width: 100%; height: auto; max-height: 78vh; } + /* ── Comment reference superscripts injected into prose ──────────────────── */ .prose .comment-ref { display: inline-flex; @@ -153,8 +290,8 @@ body { justify-content: center; width: 1.1em; height: 1.1em; - background: var(--color-yellow); - color: var(--color-grey-900); + background: var(--color-accent); + color: var(--color-ink-90); font-size: 0.65em; font-weight: 700; border-radius: 50%; @@ -166,15 +303,42 @@ body { /* ── Outline panel ───────────────────────────────────────────────────────── */ .outline-link:hover { - background: rgba(0, 0, 0, 0.06); + background: var(--color-paper-alt); } -/* ── Scrollbar (subtle) ──────────────────────────────────────────────────── */ -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: var(--color-grey-100); } -::-webkit-scrollbar-thumb { background: var(--color-grey-300); border-radius: 3px; } +/* ── Sidebar helpers ─────────────────────────────────────────────────────── */ +.sidebar-link:hover { background: var(--color-paper-alt); } + +/* ── Scrollbar (subtle, editorial) ───────────────────────────────────────── */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--color-rule); + border-radius: 999px; + border: 2px solid var(--color-paper); +} +::-webkit-scrollbar-thumb:hover { background: var(--color-ink-40); } /* ── Loading spinner ─────────────────────────────────────────────────────── */ -@keyframes vaimo-spin { - to { transform: rotate(360deg); } +@keyframes vaimo-spin { to { transform: rotate(360deg); } } +@keyframes fade-up { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.fade-up { animation: fade-up 0.35s ease-out both; } + +/* ── Byline / breadcrumb helpers ─────────────────────────────────────────── */ +.eyebrow { + font-family: var(--font-sans); + font-size: 0.6875rem; + font-weight: 500; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-ink-40); +} + +.rule-topline { + border-top: 1px solid var(--color-ink-90); + padding-top: 0.5rem; } diff --git a/app/layout.tsx b/app/layout.tsx index afe4dc1..a8d9108 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,30 @@ import type { Metadata } from "next"; +import { Fraunces, DM_Sans, JetBrains_Mono } from "next/font/google"; import "./globals.css"; import ClientLayout from "@/components/ClientLayout"; import { Analytics } from "@vercel/analytics/react"; +const fraunces = Fraunces({ + subsets: ["latin"], + variable: "--font-serif", + display: "swap", + axes: ["opsz", "SOFT"], +}); + +const dmSans = DM_Sans({ + subsets: ["latin"], + variable: "--font-sans", + display: "swap", + weight: ["400", "500", "600", "700"], +}); + +const jetbrains = JetBrains_Mono({ + subsets: ["latin"], + variable: "--font-mono", + display: "swap", + weight: ["400", "500", "600"], +}); + export const metadata: Metadata = { title: "Project Pages", description: "Internal documentation viewer", @@ -10,7 +32,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/app/view/[...path]/page.tsx b/app/view/[...path]/page.tsx index 7b7fc86..db4a969 100644 --- a/app/view/[...path]/page.tsx +++ b/app/view/[...path]/page.tsx @@ -2,7 +2,7 @@ import { notFound, redirect } from "next/navigation"; import { getServerSession } from "next-auth"; import { buildAuthOptions } from "@/lib/auth"; import { getFilteredTree, getFileContent } from "@/lib/github"; -import { renderMarkdown, extractHeadings } from "@/lib/markdown"; +import { renderMarkdown, renderMarkdownWithFrontmatter, extractHeadings } from "@/lib/markdown"; import { convertDocxToHtml } from "@/lib/docx"; import { parse as parseCsv } from "csv-parse/sync"; import MarkdownView from "@/components/FileView/MarkdownView"; @@ -58,9 +58,9 @@ export default async function ViewPage({ params }: Props) { } else if (MD_EXTS.has(ext)) { const raw = rawBuffer.toString("utf-8"); hasRelativeImages = /!\[[^\]]*\]\((?!https?:\/\/)(?!data:)[^\s)]+/.test(raw); - const html = await renderMarkdown(raw, filePath); + const { html, frontmatter } = await renderMarkdownWithFrontmatter(raw, filePath); const headings = extractHeadings(html); - content = ; + content = ; } else if (ext === "json") { const raw = rawBuffer.toString("utf-8"); let formatted: string; @@ -132,31 +132,60 @@ export default async function ViewPage({ params }: Props) { return ( <> -
- {/* Breadcrumb */} - +
+ {/* Top row — breadcrumb (left) + updated · author + downloads (right) */} +
+ - {/* File metadata bar */} -
-

{fileName}

{file.lastCommit && ( - - Updated {new Date(file.lastCommit.date).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })} · {file.lastCommit.author} - +

+ Updated {new Date(file.lastCommit.date).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })} + · + {file.lastCommit.author} +

)} -
+
{isExcalidraw && rawContentForClient && ( diff --git a/components/BranchSwitcher.tsx b/components/BranchSwitcher.tsx index 45f704b..6874e4b 100644 --- a/components/BranchSwitcher.tsx +++ b/components/BranchSwitcher.tsx @@ -2,7 +2,30 @@ import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useMemo } from "react"; + +function highlightMatch(text: string, query: string): React.ReactNode { + const q = query.trim(); + if (!q) return text; + const idx = text.toLowerCase().indexOf(q.toLowerCase()); + if (idx < 0) return text; + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + q.length)} + + {text.slice(idx + q.length)} + + ); +} export default function BranchSwitcher() { const { data: session, update } = useSession(); @@ -10,7 +33,9 @@ export default function BranchSwitcher() { const [open, setOpen] = useState(false); const [switching, setSwitching] = useState(false); const [downloading, setDownloading] = useState(false); + const [query, setQuery] = useState(""); const ref = useRef(null); + const searchRef = useRef(null); // Close dropdown on outside click useEffect(() => { @@ -23,11 +48,28 @@ export default function BranchSwitcher() { return () => document.removeEventListener("mousedown", handleClick); }, []); - if (!session || !session.accessibleBranches?.length) return null; + // When the dropdown opens, focus the search box and clear the query + useEffect(() => { + if (open) { + setQuery(""); + queueMicrotask(() => searchRef.current?.focus()); + } + }, [open]); + + const branches = session?.accessibleBranches ?? []; + const current = session?.branchName ?? ""; - // Only show the switcher if the user has more than one branch available - const branches = session.accessibleBranches; - const current = session.branchName; + const filteredBranches = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return branches; + const matches = branches.filter((b) => b.toLowerCase().includes(q)); + if (!matches.includes(current) && branches.includes(current)) { + return [current, ...matches]; + } + return matches; + }, [branches, current, query]); + + if (!session || !session.accessibleBranches?.length) return null; async function switchBranch(branch: string) { if (branch === current || switching) return; @@ -71,10 +113,11 @@ export default function BranchSwitcher() { style={{ display: "flex", alignItems: "stretch", - border: "1px solid rgba(255,255,255,0.25)", - borderRadius: "4px", + border: "1px solid var(--color-rule)", + borderRadius: "2px", overflow: "hidden", whiteSpace: "nowrap", + background: "transparent", }} > + )} +
+ + {/* Result list */} +
+ {filteredBranches.length === 0 && ( +
+ No branches match “{query}” +
+ )} + {filteredBranches.map((branch) => ( ))} +
)}
diff --git a/components/DownloadButton.tsx b/components/DownloadButton.tsx index 65f884d..91ca3a3 100644 --- a/components/DownloadButton.tsx +++ b/components/DownloadButton.tsx @@ -23,17 +23,23 @@ export default function DownloadButton({ filePath, withComments = false, withMed style={{ display: "inline-flex", alignItems: "center", - gap: "0.4rem", - padding: "0.45rem 1rem", - background: withComments ? "var(--color-grey-900)" : "var(--color-yellow)", - color: withComments ? "var(--color-white)" : "var(--color-grey-900)", - border: "none", - borderRadius: "4px", - fontSize: "0.875rem", + gap: "0.45rem", + padding: "0.55rem 0.95rem", + background: withComments ? "var(--color-ink-90)" : "transparent", + color: withComments ? "var(--color-paper)" : "var(--color-ink-90)", + border: `1px solid ${withComments ? "var(--color-ink-90)" : "var(--color-rule)"}`, + borderRadius: "2px", + fontSize: "0.6875rem", + fontFamily: "var(--font-sans)", fontWeight: 600, + letterSpacing: "0.16em", + textTransform: "uppercase", textDecoration: "none", cursor: "pointer", + transition: "border-color 0.15s, background 0.15s", }} + onMouseEnter={(e) => { if (!withComments) e.currentTarget.style.borderColor = "var(--color-ink-90)"; }} + onMouseLeave={(e) => { if (!withComments) e.currentTarget.style.borderColor = "var(--color-rule)"; }} > diff --git a/components/FileView/Frontmatter.tsx b/components/FileView/Frontmatter.tsx new file mode 100644 index 0000000..5be5f9b --- /dev/null +++ b/components/FileView/Frontmatter.tsx @@ -0,0 +1,361 @@ +import type { Frontmatter } from "@/lib/markdown"; + +interface Props { + data: Frontmatter; +} + +function formatDate(value: string): string { + const d = new Date(value); + if (Number.isNaN(d.getTime())) return value; + return d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +} + +function isIsoDate(v: unknown): v is string { + return typeof v === "string" && /^\d{4}-\d{2}-\d{2}(T|$)/.test(v); +} + +function extractLinkText(md: string): string { + const m = md.match(/\[([^\]]+)\]\(([^)]+)\)/); + return m ? m[1] : md; +} + +function extractLinkHref(md: string): string | null { + const m = md.match(/\[([^\]]+)\]\(([^)]+)\)/); + return m ? m[2] : null; +} + +const LABEL_STYLE: React.CSSProperties = { + fontFamily: "var(--font-sans)", + fontSize: "0.6875rem", + fontWeight: 500, + letterSpacing: "0.14em", + textTransform: "uppercase", + color: "var(--color-ink-40)", + margin: 0, +}; + +const VALUE_STYLE: React.CSSProperties = { + fontFamily: "var(--font-sans)", + fontSize: "0.9375rem", + lineHeight: 1.55, + color: "var(--color-ink-90)", + margin: 0, +}; + +function TagChip({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function StatusBadge({ status }: { status: string }) { + const stable = /stable|active|live/i.test(status); + const draft = /draft|wip/i.test(status); + return ( + + + {status} + + ); +} + +function renderValue(key: string, value: unknown): React.ReactNode { + if (value == null) return ; + + if (key === "status" && typeof value === "string") return ; + + if (key === "tags" && Array.isArray(value)) { + return ( +
+ {value.map((t, i) => )} +
+ ); + } + + if (value instanceof Date) { + return

{formatDate(value.toISOString())}

; + } + + if (isIsoDate(value)) { + return

{formatDate(value)}

; + } + + if (Array.isArray(value)) { + return ( +
    + {value.map((v, i) => { + const s = String(v); + const href = extractLinkHref(s); + const text = extractLinkText(s); + return ( +
  • + {href ? ( + + {text} + + ) : ( + {text} + )} +
  • + ); + })} +
+ ); + } + + if (typeof value === "object") { + return ( +
+ {JSON.stringify(value, null, 2)} +
+ ); + } + + return

{String(value)}

; +} + +const PRIORITY_KEYS = ["title", "description", "type", "status", "timestamp", "tags"]; + +/** + * Arrays with many items or long entries (e.g. `related`, `applies_to`) don't + * fit the compact 220px-min grid used for scalar fields — they get their own + * full-width row below. + */ +function isWideArray(value: unknown): boolean { + if (!Array.isArray(value)) return false; + if (value.length > 4) return true; + return value.some((v) => String(v).length > 45); +} + +export default function Frontmatter({ data }: Props) { + const keys = Object.keys(data); + if (keys.length === 0) return null; + + const primary = keys.filter((k) => PRIORITY_KEYS.includes(k)); + const secondary = keys.filter((k) => !PRIORITY_KEYS.includes(k)); + + const title = typeof data.title === "string" ? data.title : null; + const description = typeof data.description === "string" ? data.description : null; + + const allKeys = [...primary.filter((k) => k !== "title" && k !== "description"), ...secondary]; + const gridKeys = allKeys.filter((k) => k !== "tags" && !isWideArray(data[k])); + const wideKeys = allKeys.filter((k) => k !== "tags" && isWideArray(data[k])); + const tagsKey = allKeys.includes("tags") ? "tags" : null; + + return ( + + ); +} + +function WideArrayList({ items }: { items: unknown[] }) { + return ( +
+ ); +} diff --git a/components/FileView/MarkdownView.tsx b/components/FileView/MarkdownView.tsx index 3077bed..cd2624a 100644 --- a/components/FileView/MarkdownView.tsx +++ b/components/FileView/MarkdownView.tsx @@ -4,13 +4,15 @@ import { useState, useRef, useCallback, useEffect } from "react"; import CommentForm from "@/components/Comments/CommentForm"; import MermaidBlock from "./MermaidBlock"; import OutlinePanel from "./OutlinePanel"; -import type { OutlineHeading } from "@/lib/markdown"; +import Frontmatter from "./Frontmatter"; +import type { OutlineHeading, Frontmatter as FrontmatterType } from "@/lib/markdown"; interface Props { html: string; filePath: string; commentsEnabled?: boolean; headings?: OutlineHeading[]; + frontmatter?: FrontmatterType | null; } type Segment = { type: "html"; content: string } | { type: "mermaid"; code: string }; @@ -38,7 +40,7 @@ function splitMermaid(html: string): Segment[] { const BLOCK_TAGS = new Set(["P", "LI", "H1", "H2", "H3", "H4", "H5", "H6", "TD", "TH", "BLOCKQUOTE", "DT", "DD"]); -export default function MarkdownView({ html, filePath, commentsEnabled = true, headings = [] }: Props) { +export default function MarkdownView({ html, filePath, commentsEnabled = true, headings = [], frontmatter = null }: Props) { const segments = splitMermaid(html); const articleRef = useRef(null); const [iconY, setIconY] = useState(null); @@ -120,7 +122,8 @@ export default function MarkdownView({ html, filePath, commentsEnabled = true, h return (
-
+
+ {frontmatter && } {segments.map((seg, i) => seg.type === "html" ? (
diff --git a/components/FileView/MermaidBlock.tsx b/components/FileView/MermaidBlock.tsx index 7fc5d96..55d3061 100644 --- a/components/FileView/MermaidBlock.tsx +++ b/components/FileView/MermaidBlock.tsx @@ -1,6 +1,7 @@ "use client"; -import { useId, useState, useEffect } from "react"; +import { useId, useState, useEffect, useRef, useCallback } from "react"; +import { createPortal } from "react-dom"; interface Props { code: string; @@ -8,6 +9,68 @@ interface Props { let mermaidReady = false; +/** + * Palette cycled across sequence-diagram actor boxes so each participant + * is visually distinct. Kept warm and editorial — sits inside the paper + * palette rather than fighting it. `bg` / `border` pairs are matched for + * legibility of the actor label rendered inside the box. + */ +const ACTOR_PALETTE = [ + { bg: "#ffe08a", border: "#7a5300" }, // ochre + { bg: "#b8d69a", border: "#3d5a28" }, // sage + { bg: "#f2b5a8", border: "#7d2a1c" }, // coral + { bg: "#9dc4e8", border: "#1f4574" }, // sky + { bg: "#c6a6e8", border: "#432575" }, // lilac + { bg: "#f4c980", border: "#6a3a10" }, // amber + { bg: "#8fc9c1", border: "#1e4a45" }, // teal +] as const; + +function colorizeActors(root: SVGElement | HTMLElement) { + const rects = Array.from(root.querySelectorAll("rect.actor")); + // Match top and bottom bars for the same participant so each column + // reads as a single colour top and bottom. + const tops = rects.filter((r) => r.classList.contains("actor-top")); + const bottoms = rects.filter((r) => r.classList.contains("actor-bottom")); + const n = Math.max(tops.length, bottoms.length, rects.length); + for (let i = 0; i < n; i++) { + const swatch = ACTOR_PALETTE[i % ACTOR_PALETTE.length]; + for (const r of [tops[i], bottoms[i]]) { + if (!r) continue; + // Mermaid's compiled CSS wins over the SVG `fill` attribute — apply the + // colour via inline style with !important so it survives cascade. + r.style.setProperty("fill", swatch.bg, "important"); + r.style.setProperty("stroke", swatch.border, "important"); + r.style.setProperty("stroke-width", "1.5", "important"); + } + } + // If neither top nor bottom split matched, fall back to raw cycle + if (tops.length === 0 && bottoms.length === 0) { + rects.forEach((rect, i) => { + const swatch = ACTOR_PALETTE[i % ACTOR_PALETTE.length]; + rect.style.setProperty("fill", swatch.bg, "important"); + rect.style.setProperty("stroke", swatch.border, "important"); + rect.style.setProperty("stroke-width", "1.5", "important"); + }); + } + + // Flowchart / graph nodes — one distinct colour per node so multi-node + // flowcharts also read as colourful, not monochrome. + const nodes = Array.from(root.querySelectorAll( + ".node .basic.label-container, .node rect, .node polygon, .node circle, .node ellipse" + )); + const seenParents = new WeakSet(); + let m = 0; + for (const el of nodes) { + const parent = el.closest(".node"); + if (!parent || seenParents.has(parent)) continue; + seenParents.add(parent); + const swatch = ACTOR_PALETTE[m % ACTOR_PALETTE.length]; + el.style.setProperty("fill", swatch.bg, "important"); + el.style.setProperty("stroke", swatch.border, "important"); + m++; + } +} + export default function MermaidBlock({ code }: Props) { const rawId = useId(); const diagramId = `mermaid-${rawId.replace(/[^a-zA-Z0-9]/g, "")}`; @@ -15,13 +78,68 @@ export default function MermaidBlock({ code }: Props) { const [svg, setSvg] = useState(null); const [error, setError] = useState(null); const [showDiagram, setShowDiagram] = useState(true); + const [zoomed, setZoomed] = useState(false); + const frameRef = useRef(null); useEffect(() => { let cancelled = false; import("mermaid").then(({ default: mermaid }) => { if (!mermaidReady) { - mermaid.initialize({ startOnLoad: false, theme: "default" }); + mermaid.initialize({ + startOnLoad: false, + theme: "base", + themeVariables: { + fontFamily: 'var(--font-sans), "DM Sans", -apple-system, sans-serif', + fontSize: "14px", + // Primary — soft cream card, ink border + primaryColor: "#fff7e0", + primaryTextColor: "#1a1811", + primaryBorderColor: "#7a5300", + // Secondary — sage green + secondaryColor: "#e0edd4", + secondaryTextColor: "#2a3620", + secondaryBorderColor: "#4d6b3a", + // Tertiary — dusty rose + tertiaryColor: "#f3dcd8", + tertiaryTextColor: "#3a1e1a", + tertiaryBorderColor: "#8a3b30", + // Lines & notes + lineColor: "#4a3d20", + noteBkgColor: "#fff1a8", + noteBorderColor: "#a17d1a", + noteTextColor: "#3a2d05", + edgeLabelBackground: "#f7f4ec", + // Actors (sequence diagrams) + actorBkg: "#fff7e0", + actorBorder: "#7a5300", + actorTextColor: "#1a1811", + actorLineColor: "#a19070", + signalColor: "#4a3d20", + signalTextColor: "#1a1811", + labelBoxBkgColor: "#dae7f5", + labelBoxBorderColor: "#3a5c88", + labelTextColor: "#0f1e33", + loopTextColor: "#1a1811", + activationBkgColor: "#e5d9f7", + activationBorderColor: "#5a3d8a", + sequenceNumberColor: "#f7f4ec", + // Clusters / subgraphs + clusterBkg: "#f2eadc", + clusterBorder: "#8a7a55", + // State/pie + pie1: "#f4b301", + pie2: "#5a8fbb", + pie3: "#d67055", + pie4: "#7ba15a", + pie5: "#9273b7", + pie6: "#c88a3a", + pie7: "#4d6b3a", + pie8: "#a04a3a", + }, + flowchart: { curve: "basis", padding: 24, htmlLabels: true }, + sequence: { actorMargin: 60, boxMargin: 12, messageMargin: 40, noteMargin: 12 }, + }); mermaidReady = true; } mermaid @@ -35,88 +153,279 @@ export default function MermaidBlock({ code }: Props) { return () => { cancelled = true; }; }, [code, diagramId]); + // After the SVG renders, colorize actor/node fills using the palette + useEffect(() => { + if (!svg || !frameRef.current) return; + colorizeActors(frameRef.current); + }, [svg]); + + // Escape closes fullscreen + useEffect(() => { + if (!zoomed) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setZoomed(false); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [zoomed]); + return ( -
- +
+
+ {svg && showDiagram && ( + + )} + +
{showDiagram ? ( svg ? (
setZoomed(true)} + title="Double-click to open fullscreen" style={{ overflowX: "auto", - padding: "1.5rem", - background: "#fafafa", - border: "1px solid var(--color-grey-300)", - borderRadius: "6px", + padding: "2rem 1.5rem 1.5rem", + background: "var(--color-card)", + border: "1px solid var(--color-rule)", + borderRadius: "2px", textAlign: "center", + cursor: "zoom-in", + boxShadow: "0 1px 0 var(--color-rule-soft) inset", }} /> ) : error ? ( -
-            Mermaid parse error:{"\n"}{error}
-          
+
Mermaid parse error:{"\n"}{error}
) : ( -
- Rendering diagram… -
+
Rendering diagram…
) ) : ( -
{code}
+ )} + + {zoomed && svg && setZoomed(false)} />} +
+ ); +} + +function ZoomOverlay({ svg, onClose }: { svg: string; onClose: () => void }) { + const containerRef = useRef(null); + const svgRef = useRef(null); + const [scale, setScale] = useState(1); + const [tx, setTx] = useState(0); + const [ty, setTy] = useState(0); + const dragging = useRef(false); + const start = useRef({ x: 0, y: 0, tx: 0, ty: 0 }); + const [mounted, setMounted] = useState(false); + + useEffect(() => { setMounted(true); }, []); + + // After the SVG is injected via dangerouslySetInnerHTML, coerce it to fill + // the container. Mermaid sets fixed width/height attrs that keep it small. + // Depend on `mounted` too — the ref only attaches on the second render. + useEffect(() => { + if (!mounted) return; + const svgEl = svgRef.current?.querySelector("svg"); + if (!svgEl) return; + svgEl.removeAttribute("width"); + svgEl.removeAttribute("height"); + svgEl.style.width = "100%"; + svgEl.style.height = "auto"; + svgEl.style.maxHeight = "80vh"; + svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet"); + if (svgRef.current) colorizeActors(svgRef.current); + }, [svg, mounted]); + + const reset = useCallback(() => { + setScale(1); + setTx(0); + setTy(0); + }, []); + + const onWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + const delta = -e.deltaY * 0.0015; + setScale((s) => Math.min(6, Math.max(0.3, s * (1 + delta)))); + }, []); + + const onMouseDown = useCallback((e: React.MouseEvent) => { + dragging.current = true; + start.current = { x: e.clientX, y: e.clientY, tx, ty }; + }, [tx, ty]); + + const onMouseMove = useCallback((e: React.MouseEvent) => { + if (!dragging.current) return; + setTx(start.current.tx + (e.clientX - start.current.x)); + setTy(start.current.ty + (e.clientY - start.current.y)); + }, []); + + const onMouseUp = useCallback(() => { dragging.current = false; }, []); + + if (!mounted) return null; + + const overlay = ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", + inset: 0, + background: "rgba(15, 12, 8, 0.92)", + zIndex: 200, + display: "flex", + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + }} + > + {/* HUD */} +
+ Diagram — scroll to zoom · drag to pan · esc to close +
+ + + + +
+
+ +
+
- {code} - - )} + /> +
); + + return createPortal(overlay, document.body); } + +const iconBtnStyle: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: "0.35rem", + padding: "0.3rem 0.6rem", + fontSize: "0.7rem", + fontFamily: "var(--font-sans)", + fontWeight: 500, + letterSpacing: "0.06em", + background: "var(--color-paper)", + border: "1px solid var(--color-rule)", + borderRadius: "2px", + cursor: "pointer", + color: "var(--color-ink-70)", +}; + +const hudBtn: React.CSSProperties = { + minWidth: "2.5rem", + padding: "0.35rem 0.75rem", + background: "transparent", + border: "1px solid rgba(255,255,255,0.25)", + borderRadius: "2px", + color: "rgba(255,255,255,0.85)", + fontFamily: "var(--font-mono)", + fontSize: "0.75rem", + cursor: "pointer", + letterSpacing: "0.05em", +}; + +const errorStyle: React.CSSProperties = { + color: "#8b2c1f", + background: "#fbeae5", + border: "1px solid #e8bfb7", + borderRadius: "2px", + padding: "1rem", + fontSize: "0.8125rem", + whiteSpace: "pre-wrap", + margin: 0, + fontFamily: "var(--font-mono)", +}; + +const loadingStyle: React.CSSProperties = { + padding: "3rem 1.5rem", + color: "var(--color-ink-40)", + fontSize: "0.8125rem", + fontFamily: "var(--font-sans)", + letterSpacing: "0.06em", + textAlign: "center", + background: "var(--color-card)", + border: "1px solid var(--color-rule)", + borderRadius: "2px", +}; + +const sourceStyle: React.CSSProperties = { + background: "var(--color-ink-90)", + color: "var(--color-paper)", + border: "none", + borderRadius: "2px", + padding: "2.75rem 1.5rem 1.5rem", + fontSize: "0.8125rem", + overflowX: "auto", + margin: 0, + lineHeight: 1.7, + fontFamily: "var(--font-mono)", +}; diff --git a/components/FileView/OutlinePanel.tsx b/components/FileView/OutlinePanel.tsx index be16279..1e546e9 100644 --- a/components/FileView/OutlinePanel.tsx +++ b/components/FileView/OutlinePanel.tsx @@ -55,7 +55,7 @@ export default function OutlinePanel({ headings }: { headings: OutlineHeading[] return (
setOpen(true)} onMouseLeave={() => setOpen(false)} > @@ -63,15 +63,29 @@ export default function OutlinePanel({ headings }: { headings: OutlineHeading[]
-
+ diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 4c82c1a..77abda9 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -13,6 +13,21 @@ function hasImageChildren(node: NavFolder): boolean { ); } +/** + * Turns a raw path segment ("returns_lifecycle.md" / "product-catalog" / + * "ECOM_FRONTEND") into a human-readable label. Strips a trailing `.md`, + * splits on `_`/`-`/`.`, then capitalises words that are entirely lowercase + * so acronyms like `SAP`, `ECOM`, `FRONTEND` survive intact. + */ +function prettyName(raw: string): string { + const noExt = raw.replace(/\.(md|mdx)$/i, ""); + return noExt + .split(/[_\-.]+/) + .filter(Boolean) + .map((w) => (/[A-Z]/.test(w) ? w : w[0].toUpperCase() + w.slice(1))) + .join(" "); +} + const SIDEBAR_DEFAULT_WIDTH = 338; const SIDEBAR_MIN_WIDTH = 140; const SIDEBAR_MAX_WIDTH = 800; @@ -23,15 +38,32 @@ interface SidebarProps { activePath?: string; } -function FolderNode({ node, depth, activePath }: { node: NavFolder; depth: number; activePath?: string }) { +function useActivePath(): string { + const pathname = usePathname(); + if (!pathname?.startsWith("/view/")) return ""; + return pathname + .slice("/view/".length) + .split("/") + .map((s) => { + try { return decodeURIComponent(s); } catch { return s; } + }) + .join("/"); +} + +function FolderNode({ node, depth }: { node: NavFolder; depth: number }) { const storageKey = `vaimo:folder:${node.path}`; - const [open, setOpen] = useState(false); + const activePath = useActivePath(); + const containsActive = activePath === node.path || activePath.startsWith(node.path + "/"); + + // Seed open state from localStorage if present, else from whether this + // folder contains the currently-viewed file. Recompute when active path changes. + const [open, setOpen] = useState(containsActive); - // Restore persisted state on mount (client only — localStorage unavailable on server) useEffect(() => { - const saved = localStorage.getItem(storageKey); - if (saved !== null) setOpen(saved === "true"); - }, [storageKey]); + const saved = typeof window !== "undefined" ? localStorage.getItem(storageKey) : null; + if (saved !== null) setOpen(saved === "true" || containsActive); + else setOpen(containsActive); + }, [storageKey, containsActive]); const toggle = useCallback(() => { setOpen((v) => { @@ -59,13 +91,15 @@ function FolderNode({ node, depth, activePath }: { node: NavFolder; depth: numbe background: "none", border: "none", cursor: "pointer", - padding: `0.3rem ${0.75 + depth * 0.75}rem`, - fontSize: "0.7rem", - fontWeight: 600, - color: "var(--color-grey-700)", - textTransform: "uppercase", - letterSpacing: "0.04em", + padding: `0.42rem ${1.25 + depth * 0.85}rem`, + fontSize: depth === 0 ? "0.6875rem" : "0.8125rem", + fontFamily: "var(--font-sans)", + fontWeight: depth === 0 ? 700 : 600, + color: containsActive ? "var(--color-ink-90)" : "var(--color-ink-90)", + textTransform: depth === 0 ? "uppercase" : "none", + letterSpacing: depth === 0 ? "0.18em" : "0.005em", overflow: "hidden", + transition: "color 0.15s, background 0.15s", }} > - {node.name} + {prettyName(node.name)} {showGallery && ( @@ -95,9 +130,9 @@ function FolderNode({ node, depth, activePath }: { node: NavFolder; depth: numbe padding: "0.15rem 0.35rem", fontSize: "0.6rem", fontWeight: 600, - color: "var(--color-grey-500)", - border: "1px solid var(--color-grey-300)", - borderRadius: "3px", + color: "var(--color-ink-40)", + border: "1px solid var(--color-rule)", + borderRadius: "2px", textDecoration: "none", letterSpacing: "0.02em", whiteSpace: "nowrap", @@ -110,7 +145,7 @@ function FolderNode({ node, depth, activePath }: { node: NavFolder; depth: numbe {open && (
    {node.children.map((child) => ( - + ))}
)} @@ -118,41 +153,56 @@ function FolderNode({ node, depth, activePath }: { node: NavFolder; depth: numbe ); } -function FileNode({ node, depth, activePath }: { node: NavNode & { type: "file" }; depth: number; activePath?: string }) { - const pathname = usePathname(); +function FileNode({ node, depth }: { node: NavNode & { type: "file" }; depth: number }) { + const activePath = useActivePath(); const href = `/view/${node.path.split("/").map(encodeURIComponent).join("/")}`; - const isActive = pathname === href || node.path === activePath; + const isActive = node.path === activePath; return (
  • - {node.name} + {isActive ? ( + + + + ) : ( + + )} + {prettyName(node.name)}
  • ); } -function NavItem({ node, depth, activePath }: { node: NavNode; depth: number; activePath?: string }) { - if (node.type === "folder") return ; - return ; +function NavItem({ node, depth }: { node: NavNode; depth: number }) { + if (node.type === "folder") return ; + return ; } -export default function Sidebar({ tree, isOpen, activePath }: SidebarProps) { +export default function Sidebar({ tree }: SidebarProps) { const [width, setWidth] = useState(SIDEBAR_DEFAULT_WIDTH); const dragging = useRef(false); const startX = useRef(0); @@ -180,6 +230,15 @@ export default function Sidebar({ tree, isOpen, activePath }: SidebarProps) { window.addEventListener("mouseup", onMouseUp); }, [width]); + // Auto-scroll the active file into view on load + useEffect(() => { + const t = setTimeout(() => { + const active = document.querySelector('aside[aria-label="Navigation"] a[style*="rgba(15, 14, 11"], aside[aria-label="Navigation"] a[style*="accent-tint"]'); + active?.scrollIntoView({ block: "center", behavior: "auto" }); + }, 50); + return () => clearTimeout(t); + }, []); + return (