From 9d32bd2fae71a4072206224121be952691676654 Mon Sep 17 00:00:00 2001 From: Patryk Walus Date: Fri, 28 Aug 2026 12:46:11 +0200 Subject: [PATCH 1/7] feat: editorial UI refresh + local-dev backdoors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI / design - Editorial Vaimo × Byredo palette (warm cream paper, near-black ink, ochre accent). Fraunces (variable serif) + DM Sans + JetBrains Mono loaded via next/font. - Sign-in redesigned as a split editorial cover; nav becomes an ink-on- paper masthead with a monogram; sidebar gets a "Contents" masthead and auto-expands + scrolls to the currently-viewed file, which is now marked with a full ochre-tint band, thicker accent border, and a checkmark. Sidebar file/folder rows are pretty-named (underscores and hyphens turned into spaces, .md dropped, acronyms preserved). Frontmatter - gray-matter now parses YAML frontmatter into a structured "At a glance" block: serif title, italic description, grid of scalar metadata, dedicated tags row, and full-width one-per-line list for long arrays (e.g. related, applies_to). - Duplicate # Title in the body is stripped when frontmatter has a matching title. Mermaid - Themed to the palette; per-participant colour cycled across actor columns (matched top/bottom) and flowchart nodes via inline style with !important to defeat mermaid's compiled CSS. - Fullscreen zoom overlay via createPortal (escapes the article's transform-containing-block created by the fade-up animation) with wheel-zoom, drag-pan, HUD, and Escape-to-close. SVG is resized to fill 88vw x 84vh. Prose - Restored native list markers (Tailwind v4 preflight zaps them; the fix uses !important because unlayered styles didn't win over the layered reset in this Turbopack build). Ochre marker colour. - Fraunces headings with optical sizing, ruled tables with uppercase sans headers, dark-ground code blocks with warm syntax colours, serif-italic blockquotes. - Removed the .prose 780px cap so the article fills its column. Header / metadata - Removed the "File / .md" block from view pages; breadcrumbs are pretty-named, and the "Updated - " line moved to the top-right of the header row alongside the download buttons. Local-dev backdoors (env-gated, default off — no prod impact) - PROJECTPAGES_LOCAL_CONFIG= in lib/github.ts: read projectpages.config from disk instead of the GitHub API. - DEV_AUTH_BYPASS=1 in lib/auth.ts: skip the passphrase check and log in as the first configured user group. - .gitignore: projectpages.config.local (sibling of the local env file convention). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + app/auth/signin/page.tsx | 343 ++++++++++++++------- app/globals.css | 296 ++++++++++++++---- app/layout.tsx | 24 +- app/view/[...path]/page.tsx | 77 +++-- components/BranchSwitcher.tsx | 67 ++-- components/DownloadButton.tsx | 20 +- components/FileView/Frontmatter.tsx | 361 ++++++++++++++++++++++ components/FileView/MarkdownView.tsx | 9 +- components/FileView/MermaidBlock.tsx | 443 +++++++++++++++++++++++---- components/FileView/OutlinePanel.tsx | 66 ++-- components/Sidebar.tsx | 148 ++++++--- components/TopNav.tsx | 114 +++++-- lib/auth.ts | 9 +- lib/github.ts | 9 + lib/markdown.ts | 42 ++- 16 files changed, 1628 insertions(+), 403 deletions(-) create mode 100644 components/FileView/Frontmatter.tsx 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/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index c0e92fc..d46d055 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -5,7 +5,6 @@ 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 +15,191 @@ 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 (
-
-
- Vaimo + {/* Decorative rule */} +
+
+ +
+ + V + + + + 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..7df8815 100644 --- a/components/BranchSwitcher.tsx +++ b/components/BranchSwitcher.tsx @@ -71,10 +71,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", }} > +
+
+ {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 (
    From 7464280dcbd1855afb5919b325d5d290c7dfa5b4 Mon Sep 17 00:00:00 2001 From: Patryk Walus Date: Fri, 28 Aug 2026 13:07:28 +0200 Subject: [PATCH 4/7] feat: use Vaimo logo in TopNav and sign-in cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TopNav: replaces the V-monogram + eyebrow with the actual Vaimo logo (dark, ochre X + wordmark) followed by a hairline slash and the serif site title. - Sign-in cover: replaces the V-disc with the white Vaimo logo on the dark left panel; site name reduced to "Project Pages". Adds public/vaimo-logo-dark.png (the raster logo with a dark wordmark — the SVG in the repo turned out to be white-fill only, so it disappeared on the light navbar). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/auth/signin/page.tsx | 37 ++++++++---------- components/TopNav.tsx | 78 ++++++++++++------------------------- public/vaimo-logo-dark.png | Bin 0 -> 12601 bytes 3 files changed, 41 insertions(+), 74 deletions(-) create mode 100644 public/vaimo-logo-dark.png diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index d46d055..e74c0df 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -1,6 +1,7 @@ "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"; @@ -51,28 +52,24 @@ function SignInForm() {
    -
    +
    + Vaimo - V - - + /> - Vaimo · Project Pages + Project Pages
    diff --git a/components/TopNav.tsx b/components/TopNav.tsx index ed4128d..9ee858b 100644 --- a/components/TopNav.tsx +++ b/components/TopNav.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import { signOut } from "next-auth/react"; import BranchSwitcher from "./BranchSwitcher"; import SectionTabs from "./SectionTabs"; @@ -48,68 +49,37 @@ export default function TopNav({ siteTitle, onMenuToggle, chatEnabled = false }: - {/* Monogram — V mark on an ochre disc */} -
    + {/* Vaimo logo (dark) + serif site title, separated by a hairline slash */} +
    + Vaimo + - V - + {siteTitle} -
    - - Vaimo · Project Pages - - - {siteTitle} - -
    diff --git a/public/vaimo-logo-dark.png b/public/vaimo-logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..fe9ce25afc7c390228ad4169aaaefa06f6f5830f GIT binary patch literal 12601 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 Date: Fri, 28 Aug 2026 13:08:10 +0200 Subject: [PATCH 5/7] feat: passphrase override via environment variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-user-group env override so real passphrases can live in the deployment environment instead of the checked-in projectpages.config (which sits in the docs repository and is therefore not secret-safe). Var name: PROJECTPAGES_PASSPHRASE_ — the group name is upper-cased, non-alphanumerics become underscores. Examples: userGroup: vaimo → PROJECTPAGES_PASSPHRASE_VAIMO userGroup: external-partner → PROJECTPAGES_PASSPHRASE_EXTERNAL_PARTNER Precedence: env value wins when set and non-empty; otherwise the config value is used unchanged. An accidental unset does not silently lock everyone out — the config value is still honoured. Teams can now commit projectpages.config with blank passphrase strings (or placeholder text) and set the real secret only in the deployment's environment. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/auth.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/auth.ts b/lib/auth.ts index 2e78aba..48a1c57 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -6,6 +6,27 @@ import { getConfig } from "./github"; const ENABLE_GOOGLE = process.env.ENABLE_GOOGLE_LOGIN === "true" || process.env.ENABLE_GOOGLE_LOGIN === "1"; +/** + * Resolves a user group's passphrase, preferring an environment variable + * over whatever's in projectpages.config. This lets teams keep the config + * file (which lives in the docs repository) secret-free and set the real + * passphrase per environment. + * + * Env var name: PROJECTPAGES_PASSPHRASE_ — the group name is + * upper-cased and any non-alphanumeric character becomes an underscore. + * Examples: + * userGroup "vaimo" → PROJECTPAGES_PASSPHRASE_VAIMO + * userGroup "external-partner" → PROJECTPAGES_PASSPHRASE_EXTERNAL_PARTNER + * + * Falls back to `configPassphrase` when the env var is unset — so an + * accidental miss doesn't silently lock everyone out of the docs. + */ +function resolveGroupPassphrase(groupName: string, configPassphrase: string): string { + const envKey = `PROJECTPAGES_PASSPHRASE_${groupName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`; + const fromEnv = process.env[envKey]; + return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : configPassphrase; +} + export async function buildAuthOptions(): Promise { let sessionMaxAge = 7 * 24 * 60 * 60; // default: 7 days in seconds @@ -108,7 +129,9 @@ export async function buildAuthOptions(): Promise { const userGroup = bypass ? config.userGroups[0] - : config.userGroups.find((g) => g.passphrase === credentials!.passphrase); + : config.userGroups.find( + (g) => resolveGroupPassphrase(g.name, g.passphrase) === credentials!.passphrase, + ); if (!userGroup) return null; const accessibleBranches = getAccessibleBranches(userGroup.name, config); From c7963e80ee12ae8670bb37b09f3119ad7b5697e6 Mon Sep 17 00:00:00 2001 From: Patryk Walus Date: Fri, 28 Aug 2026 13:12:29 +0200 Subject: [PATCH 6/7] docs: refresh README for the editorial/local-dev additions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reframes "how it works" step 2 around user groups (not per-branch passphrases), and mentions env-var passphrase overrides. - New "Features" section highlighting the editorial UI, frontmatter rendering, filterable branch switcher, and coloured mermaid zoom. - New "Quick start (local, no config in the docs repository)" section covering the local-dev flow: PROJECTPAGES_LOCAL_CONFIG + PROJECTPAGES_PASSPHRASE_ + optional DEV_AUTH_BYPASS. - Documents the discoverBranches config flag. The docs/ tree wasn't touched — deeper reference material still lives in there and is linked from the same table. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 8 deletions(-) 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 | From ef6a890dcee9e8a9c24bc60915932197fdd5e25d Mon Sep 17 00:00:00 2001 From: Patryk Walus Date: Fri, 28 Aug 2026 14:07:12 +0200 Subject: [PATCH 7/7] docs: sync deep-doc pages with the editorial/local-dev changes - authentication.md: rewrites the passphrase flow around user groups (was per-branch), documents PROJECTPAGES_PASSPHRASE_ env override, and DEV_AUTH_BYPASS. Corrects the sign-in diagram and JWT contents. - configuration.md: adds env-var passphrase note to userGroups, adds the discoverBranches field with template-inheritance semantics, notes PROJECTPAGES_LOCAL_CONFIG in the intro. - deployment.md: adds PROJECTPAGES_PASSPHRASE_, PROJECTPAGES_LOCAL_CONFIG, and DEV_AUTH_BYPASS rows to the environment-variables table. - content-rendering.md: adds bullets for YAML frontmatter rendering and Mermaid zoom/colour; refreshes the sidebar description (pretty names, auto-expand, active-file band, drag-resize). - design.md: full palette rewrite for the editorial tokens (paper/ink/rule/accent) with legacy-alias table; documents the Fraunces + DM Sans + JetBrains Mono type stack loaded via next/font. - project-structure.md: adds new components (Frontmatter, MermaidBlock, BranchSwitcher, OutlinePanel) and updates the key-files table with the current lib/ responsibilities. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/authentication.md | 80 +++++++++++++++++++++++++++++++-------- docs/configuration.md | 35 ++++++++++++++--- docs/content-rendering.md | 10 +++-- docs/deployment.md | 3 ++ docs/design.md | 77 ++++++++++++++++++++++++++++--------- docs/project-structure.md | 52 ++++++++++++++++--------- 6 files changed, 197 insertions(+), 60 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index e481d55..9fc9bb9 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -2,26 +2,71 @@ ## Provider -**Shared passphrase** via NextAuth.js `Credentials` provider. Any visitor who enters a correct passphrase is granted a session scoped to the corresponding Git branch. There is no per-user identity — all users who know the same passphrase share the same branch view. +**Shared passphrase per user group** via NextAuth.js `Credentials` provider. Any visitor who enters a correct passphrase is granted a session scoped to that group's accessible Git branches. There is no per-user identity — all users who know the same passphrase share the same access. -## How It Works +## How it works -Passphrases are defined in `projectpages.config` under the `branches` list — one passphrase per branch. The app reads this list at authentication time and matches the submitted passphrase. +Passphrases and permissions are declared in `projectpages.config`: + +```yaml +userGroups: + - name: vaimo + passphrase: "" # blank; the real one comes from env + - name: client + passphrase: "" + +branches: + - name: master + userGroups: [vaimo, client] + - name: internal + userGroups: [vaimo] +``` + +At sign-in the app resolves the submitted passphrase to a user group, then exposes every branch that group is listed on: ``` User submits passphrase "abc123" - └──▶ app reads projectpages.config - └──▶ finds branch { name: "client", passphrase: "abc123" } - └──▶ session JWT is issued with branchName = "client" + └──▶ app resolves it to user group { name: "vaimo" } + └──▶ accessible branches = [master, internal] + └──▶ session JWT issued with: + userGroupName = "vaimo" + branchName = "master" (first accessible branch) + accessibleBranches = [master, internal] +``` + +Once signed in the top-nav switcher lets the user hop between any of their accessible branches. The switcher is searchable and, with `discoverBranches: true`, auto-populates from the repo. + +## Passphrase source (env override — recommended) + +By default a passphrase is read from `projectpages.config` under `userGroups[].passphrase`. Because that file ships in the docs repository, it is not a secret-safe location. Prefer setting the real value in an environment variable: + +``` +PROJECTPAGES_PASSPHRASE_ ``` -All subsequent requests read `branchName` from the JWT and fetch content from that Git branch. +The group name is upper-cased and any non-alphanumeric character becomes an underscore. Examples: + +| Group | Env variable | +|---|---| +| `vaimo` | `PROJECTPAGES_PASSPHRASE_VAIMO` | +| `client` | `PROJECTPAGES_PASSPHRASE_CLIENT` | +| `external-partner` | `PROJECTPAGES_PASSPHRASE_EXTERNAL_PARTNER` | + +**Precedence:** env value wins when set and non-empty; otherwise the config value is used unchanged. This means an accidental unset does not silently lock everyone out — the config value is still honoured. + +Leave the config's `passphrase:` empty (or a placeholder) so committing the config never exposes a working secret. + +## Session storage + +Sessions use the **JWT strategy** (no database required). The token is stored in a secure, HTTP-only cookie and contains: -## Session Storage +- `userGroupName` — the group the visitor authenticated as +- `branchName` — the currently-viewed branch +- `accessibleBranches` — the branches the group can switch between -Sessions use the **JWT strategy** (no database required). The token is stored in a secure, HTTP-only cookie. The token contains the `branchName` that was resolved at sign-in. +Branch switches update `branchName` on the same token; the group and accessible-branches list stay pinned to whatever was resolved at sign-in. -## Session Duration +## Session duration Configured in `projectpages.config`: @@ -32,22 +77,25 @@ auth: Defaults to 7 days if omitted. -## Protected Routes +## Protected routes All routes except `/api/auth/**` and `/auth/error` require a valid session. Unauthenticated requests are redirected to `/auth/signin`. -## Environment Variables +## Environment variables | Variable | Description | |---|---| | `NEXTAUTH_SECRET` | Random string used to sign and encrypt JWT tokens. Rotate this to invalidate all sessions. | | `NEXTAUTH_URL` | Canonical URL of the deployment (e.g. `https://vaimopages.vercel.app`). Required by NextAuth for redirect construction. | +| `PROJECTPAGES_PASSPHRASE_` | Per-group passphrase override. See above. 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 configured user group. **Development only — never set in production.** | -> The `AUTH_PASSPHRASE` environment variable used in earlier versions is no longer required. Passphrases are now defined entirely in `projectpages.config`. +> The `AUTH_PASSPHRASE` environment variable used in earlier versions is no longer required. Passphrases are now defined per-user-group in `projectpages.config` and/or `PROJECTPAGES_PASSPHRASE_`. -## Security Notes +## Security notes - Passphrases are compared server-side only, inside the NextAuth credentials handler. They are never sent to the client. -- Passphrases are stored as plain text in `projectpages.config`. Treat the config file as a secret and restrict access to the docs repository accordingly. -- Rotating a passphrase in the config takes effect immediately (within the 60-second config cache window). Existing sessions remain valid until they expire naturally. +- Prefer `PROJECTPAGES_PASSPHRASE_` for real values. Only put a plain-text passphrase in `projectpages.config` if you're comfortable with everyone with docs-repo access being able to read it. +- Rotating a passphrase (env var **or** config value) takes effect within the 60-second config cache window. Existing sessions remain valid until they expire naturally — the passphrase is only checked at sign-in, not per request. - Rotating `NEXTAUTH_SECRET` invalidates **all** existing sessions immediately — users will need to re-enter their passphrase. +- `DEV_AUTH_BYPASS=1` accepts *any* input (including an empty string once the credentials handler is skipped upstream) as a valid login. It exists to speed up local iteration and must never be set on a public deployment. diff --git a/docs/configuration.md b/docs/configuration.md index b5edd98..329d51f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,6 +2,8 @@ This YAML file must exist at the root of your documentation repository in the branch(es) configured by `CONFIG_BRANCH` (default: `master,main`). It does **not** need to be present in every content branch — the app reads it from a single designated branch on every request (with a 60-second in-memory cache). +**Local-development shortcut:** set `PROJECTPAGES_LOCAL_CONFIG=/absolute/path/to/config.yaml` and the app reads the config from disk instead of the docs repo. The docs repo can stay clean — nothing has to be committed there. See the [Quick start (local)](../README.md#quick-start-local-no-config-in-the-docs-repository) section of the README. + The repository itself is identified by the `DOCS_REPO` environment variable set in the Project Pages deployment — **not** by anything inside this config file. See [Deployment → DOCS_REPO](./deployment.md#docs_repo) for details. A ready-to-copy template is provided at [`projectpages.config.example`](../projectpages.config.example) in this repository. @@ -19,10 +21,17 @@ auth: sessionDurationDays: 7 userGroups: + # Real passphrases can live here, OR (recommended) in + # PROJECTPAGES_PASSPHRASE_ env vars — env wins when set. - name: vaimo - passphrase: "replace-with-secret" + passphrase: "" - name: client - passphrase: "replace-with-another-secret" + passphrase: "" + +# Optional: auto-list every branch in DOCS_REPO in addition to the +# `branches:` entries below. Discovered branches inherit permissions +# from the first explicit entry (used as a template). +discoverBranches: true branches: - name: master @@ -84,9 +93,11 @@ Defines the audiences that can access this portal. At least one entry is require | Field | Required | Description | |---|---|---| | `userGroups[].name` | Yes | Identifier for the group (referenced by branches) | -| `userGroups[].passphrase` | Yes | Plain-text passphrase that authenticates a user as this group | +| `userGroups[].passphrase` | Yes | Plain-text passphrase, used only as a fallback when the env var is unset. See below. | + +Passphrases must be unique across all groups. -Passphrases must be unique across all groups. Treat this file as a secret. +**Prefer env-var passphrases.** Because this file lives in the docs repo, plain-text passphrases here are visible to anyone with repo access. Set the real value in a `PROJECTPAGES_PASSPHRASE_` environment variable — env wins when set and non-empty. Group names are upper-cased and non-alphanumerics become underscores (e.g. `vaimo` → `PROJECTPAGES_PASSPHRASE_VAIMO`, `external-partner` → `PROJECTPAGES_PASSPHRASE_EXTERNAL_PARTNER`). Full details in [Authentication → Passphrase source](./authentication.md#passphrase-source-env-override--recommended). ### `branches` @@ -99,7 +110,19 @@ A list of Git branches that Project Pages can serve. At least one entry is requi | `branches[].comments.enabled` | No | Whether inline comments are enabled for this branch. Defaults to `false`. | | `branches[].chat.backendUrl` | No | Base URL of the LightRAG-compatible chat service indexed over this branch's content. When present, the **Chat** tab appears in the top nav while this branch is active. Omit to hide chat on this branch. See [Chat](#chat) below for the rationale. | -**How it works:** When a user logs in, the app identifies their group by passphrase, then finds all branches that list that group. The user lands on the first accessible branch. If multiple branches are accessible, a branch switcher appears in the top nav so they can move between them without logging out. +**How it works:** When a user logs in, the app identifies their group by passphrase, then finds all branches that list that group. The user lands on the first accessible branch. If multiple branches are accessible, a searchable branch switcher appears in the top nav so they can move between them without logging out. + +### `discoverBranches` + +| Field | Required | Description | +|---|---|---| +| `discoverBranches` | No | When `true`, the app calls the GitHub `listBranches` API for `DOCS_REPO` and merges every branch into the branch list at load time. Defaults to `false`. | + +Discovered branches inherit `userGroups` / `comments` / `chat` from the **first** explicit `branches:` entry (used as a template), so you still need at least one hand-declared branch. Explicit entries keep their own settings — discovery only adds branches that aren't already declared. + +Fails soft: if the API call errors, the app logs a warning and falls back to the declared branches only. + +Best paired with the top-nav switcher's search box for repos with dozens of branches. ### `include` @@ -227,5 +250,5 @@ The `indexing` section follows the same glob syntax — see [`indexing`](#indexi - The file must be named exactly `projectpages.config` (no extension) and live at the repository root. - `site.title`, at least one `include` pattern, at least one `userGroups` entry, and at least one `branches` entry are required. - The file is parsed as YAML — check indentation and quoting if you see parse errors in Vercel logs. -- Passphrases are compared exactly (case-sensitive, whitespace-sensitive). +- Passphrases are compared exactly (case-sensitive, whitespace-sensitive). If a `PROJECTPAGES_PASSPHRASE_` env var is set, the config's `passphrase:` is ignored for that group — set the env var to the empty string to fall back to the config value. - The repository being read is determined entirely by the `DOCS_REPO` env var in the app — there is no `source.repo` field in this config. diff --git a/docs/content-rendering.md b/docs/content-rendering.md index 7125f8c..c109e07 100644 --- a/docs/content-rendering.md +++ b/docs/content-rendering.md @@ -25,6 +25,8 @@ Each file page shows: - External links open in a new tab. - Images referenced in Markdown are proxied through `/api/raw?path=...` so they respect the GitHub token for private repos. - Comment references are injected at render time when comments exist (see [Comments](./comments.md)). +- **YAML frontmatter** at the top of a Markdown file is parsed out of the body and rendered above the article as an editorial "At a glance" block — serif title, italic description, metadata grid (with tag chips and formatted dates), and full-width sections for long arrays like `related` or `applies_to`. A leading `# Title` in the body that matches `title:` in the frontmatter is stripped so the title isn't rendered twice. +- **Mermaid diagrams** (` ```mermaid ` code blocks) render inline with two controls: **Source** flips to the raw code, **Zoom** opens a fullscreen overlay with wheel-zoom, drag-pan, and Escape-to-close. Actors in sequence diagrams and nodes in flowcharts are auto-coloured with a per-participant palette so multi-actor diagrams read at a glance. ## Navigation @@ -44,9 +46,11 @@ Each file page shows: ### Sidebar - Built from the filtered file tree (config `include`/`exclude` rules applied to the authenticated branch). -- Folder names are collapsible groups. -- Files are listed as links within their group. -- The active file is highlighted. +- Folder names are collapsible groups; the folder(s) containing the currently-viewed file auto-expand on load. +- Files and folders are pretty-named: `.md` extensions are stripped and `_`/`-`/`.` become spaces (`returns_lifecycle.md` → "Returns Lifecycle"). Words already containing uppercase are preserved so acronyms like `SAP` and `ECOM_FRONTEND` survive. +- The active file gets a full ochre-tint band, thicker accent border, and a checkmark; the sidebar auto-scrolls it into view. +- Folder rows are visually heavier than file rows so the hierarchy is easy to scan. +- The sidebar is drag-resizable via the right-hand grip. - On mobile, the sidebar collapses to a hamburger menu. ### Home / Index Page diff --git a/docs/deployment.md b/docs/deployment.md index 0d1cc49..266b3c2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -51,6 +51,9 @@ npm run dev | `CHAT_CHUNK_TOP_K` | No | Default LightRAG `chunk_top_k`. Defaults to `25`. | | `CHAT_LANGUAGE_INSTRUCTION` | No | Forwarded as `user_prompt` on every query. Defaults to "You MUST respond in English." (works around DeepSeek language drift). | | `CHAT_TIMEOUT_MS` | No | Abort the chat backend call after this many milliseconds. Defaults to `120000`. | +| `PROJECTPAGES_PASSPHRASE_` | No | Per-user-group passphrase override. Group name is upper-cased, non-alphanumerics become underscores (e.g. `vaimo` → `PROJECTPAGES_PASSPHRASE_VAIMO`). Wins over the value in `projectpages.config`. Recommended so real secrets never live in the docs repo. | +| `PROJECTPAGES_LOCAL_CONFIG` | No | Absolute path to a `projectpages.config`-shaped YAML file on the deployment's filesystem. When set, the app reads config from disk instead of the GitHub API. Intended for local dev — set only via `.env.local`, never on Vercel. | +| `DEV_AUTH_BYPASS` | No | Set to `1` to skip the passphrase check entirely and log in as the first user group. **Development only — never set in production.** | --- diff --git a/docs/design.md b/docs/design.md index 241530b..41457e0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,32 +1,73 @@ # Branding & Design -## Vaimo Brand Tokens +## Palette — editorial (Vaimo × Byredo) + +Warm cream paper, near-black ink, hairline stone rules, one ochre accent used sparingly. Legacy grey-\*/yellow tokens still exist as aliases so pre-refresh code keeps working. | Token | Value | Usage | |---|---|---| -| `--color-grey-900` | `#1a1a1a` | Body text (never pure black) | -| `--color-grey-700` | `#404040` | Secondary text, labels | -| `--color-grey-300` | `#c8c8c8` | Borders, dividers | -| `--color-grey-100` | `#f2f2f2` | Page background, sidebar background | -| `--color-yellow` | `#f5c400` | Accent — CTAs, active nav item indicator, highlights | -| `--color-white` | `#ffffff` | Content area background, card backgrounds | +| `--color-paper` | `#f7f4ec` | Page background | +| `--color-paper-alt` | `#efeadb` | Sidebar highlights, secondary surfaces | +| `--color-card` | `#fdfbf6` | Article ground, popover surfaces | +| `--color-ink-90` | `#0f0e0b` | Body ink (never pure black) | +| `--color-ink-70` | `#3a362e` | Body copy default | +| `--color-ink-40` | `#857e6d` | Muted labels, timestamps | +| `--color-rule` | `#d6cfba` | Hairline borders, dividers | +| `--color-rule-soft` | `#e7e1cf` | Subtle inset lines | +| `--color-accent` | `#f4b301` | Ochre — CTAs, active nav, marker colour | +| `--color-accent-ink` | `#7a5300` | Accent-tone text on cream surfaces | +| `--color-accent-tint` | `#fff1c2` | Active-row background, status badge fill | +| `--color-ink-invert` | `#fbf7ec` | Text on ink-90 backgrounds | + +### Legacy aliases + +These map onto the new tokens so older component styles keep working: + +| Legacy | Now points to | +|---|---| +| `--color-grey-900` | `--color-ink-90` | +| `--color-grey-700` | `--color-ink-70` | +| `--color-grey-500` | `--color-ink-40` | +| `--color-grey-300` | `--color-rule` | +| `--color-grey-100` | `--color-paper-alt` | +| `--color-yellow` | `--color-accent` | +| `--color-white` | `--color-card` | ## Typography -- **Font family**: System font stack (`-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`). -- **Base size**: 16px. -- **Headings**: Semi-bold, `--color-grey-900`. -- **Body**: Regular, `--color-grey-900`. -- **Code**: Monospace (`ui-monospace, 'Cascadia Code', 'Fira Mono', monospace`), on a light grey background. +Loaded via `next/font/google` in `app/layout.tsx` and exposed as CSS variables — pick the right one per element, never fall back to system defaults for anything user-facing. + +| Family | Variable | Usage | +|---|---|---| +| **Fraunces** (variable serif, `opsz` + `SOFT` axes) | `--font-serif` | Display type: article headings, frontmatter title/description, sign-in headline, italic emphasis in the sidebar sign-in. | +| **DM Sans** | `--font-sans` | Body copy, labels, sidebar, buttons, small caps. | +| **JetBrains Mono** | `--font-mono` | Inline `code`, code blocks, timestamps in the branch dropdown, file names in the header. | + +Base size: 16px. Body line-height 1.75. Fraunces headings use optical sizing (`opsz` at 36–144 depending on level) and a slight softness (`SOFT` 20–30) so display type has some warmth without becoming decorative. ## Layout Principles -- Generous whitespace; no cramped layouts. -- Max content width: 800px for text-heavy pages, full-width for CSV tables. -- Sidebar: 260px fixed on desktop, full-screen overlay on mobile. -- No drop shadows — use `1px solid --color-grey-300` borders for separation. -- Yellow accent used **sparingly**: active nav link left border, primary button background, focus ring. +- Generous whitespace; no cramped layouts. The article column fills the main pane (no fixed max-width) — long paragraphs are allowed to be wider than 800px because tables and diagrams need the room. +- Sidebar: 338px default on desktop, drag-resizable between 140px and 800px, full-screen overlay on mobile. +- Content padding: `1.5rem 2.5rem 4rem` around `
    `. +- **No drop shadows on standard surfaces** — separate with `1px solid var(--color-rule)`. Reserve shadows for elevated popovers (branch switcher, outline panel, mermaid zoom overlay). +- Ochre accent used **sparingly**: active nav item background + border + marker, ochre marker in prose lists, single ochre rule on the sign-in cover, and the accent tint on branch-switcher search hits. +- Faint paper grain: two very low-opacity radial gradients on `` add texture without noise. ## Logo -The Vaimo logo (SVG/WebP) is served as a static asset from `/public/`. Both full-colour and white variants are included. The header uses the full-colour variant on light backgrounds. +The Vaimo logo is served from `/public/`. Variants: + +| Path | When to use | +|---|---| +| `vaimo-logo-dark.png` | Light backgrounds (top nav). Dark wordmark + ochre X-mark. | +| `vaimo-logo-white.svg` | Dark backgrounds (sign-in cover left panel). | +| `vaimo-logo.svg` / `vaimo-logo-white.png` / `vaimo-logo.webp` | Legacy assets, kept for reference. Prefer the two above. | + +Both used variants are rendered via `next/image` with an explicit height (28–36px) and `width: auto` for aspect-preserving scaling. + +## Detail tokens + +- `::selection` background is `--color-accent`. +- Scrollbars are 8px, rounded, with `--color-rule` thumbs on transparent tracks. +- Fade-up entrance on `
    ` via the `.fade-up` keyframe (`0.35s` ease-out). Kept subtle to avoid content pop-in feeling animated. diff --git a/docs/project-structure.md b/docs/project-structure.md index 364dd4e..ddb2436 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -27,36 +27,50 @@ project-pages/ │ └── github/route.ts # GitHub push webhook handler │ ├── components/ -│ ├── Sidebar.tsx -│ ├── TopNav.tsx +│ ├── Sidebar.tsx # Nav tree: pretty-names, auto-expand, drag-resize +│ ├── TopNav.tsx # Logo + site title + branch switcher + sign out +│ ├── BranchSwitcher.tsx # Searchable branch dropdown │ ├── FileView/ │ │ ├── MarkdownView.tsx +│ │ ├── Frontmatter.tsx # YAML frontmatter "At a glance" block +│ │ ├── MermaidBlock.tsx # Inline diagram + fullscreen zoom overlay +│ │ ├── OutlinePanel.tsx # Hover-to-expand headings panel │ │ ├── CsvView.tsx -│ │ └── ImageView.tsx +│ │ ├── ImageView.tsx +│ │ ├── ExcalidrawView.tsx +│ │ └── SubtitleView.tsx │ ├── Comments/ │ │ ├── CommentPanel.tsx │ │ ├── CommentThread.tsx │ │ └── CommentForm.tsx -│ └── DownloadButton.tsx +│ ├── DownloadButton.tsx +│ ├── ExcalidrawPngButton.tsx +│ ├── ConfigError.tsx +│ ├── SectionTabs.tsx +│ └── ClientLayout.tsx │ ├── lib/ -│ ├── github.ts # GitHub API client, config loader, file fetching +│ ├── github.ts # GitHub API client, config loader (with local override + branch discovery) │ ├── supabase.ts # Supabase client, comment CRUD -│ ├── config.ts # projectpages.config parser + glob filter -│ ├── auth.ts # NextAuth options (branch-based passphrase auth) +│ ├── config.ts # projectpages.config parser + glob filter + discoverBranches flag +│ ├── auth.ts # NextAuth options (per-user-group passphrase, env override, DEV_AUTH_BYPASS) │ ├── nav.ts # File tree → sidebar nav builder -│ ├── markdown.ts # Markdown rendering + comment annotation +│ ├── markdown.ts # Markdown + frontmatter (gray-matter) + comment annotation +│ ├── chat.ts # LightRAG chat backend adapter │ └── docx.ts # DOCX preview support │ ├── types/ -│ └── next-auth.d.ts # Session type extension (branchName) +│ └── next-auth.d.ts # Session type extension (branchName, userGroupName, accessibleBranches) │ -├── styles/ -│ └── globals.css # CSS custom properties (brand tokens) +├── app/globals.css # Palette tokens, prose styles, editorial fonts wired via next/font │ ├── public/ -│ ├── vaimo-logo.webp -│ └── vaimo-logo-white.svg +│ ├── vaimo-logo-dark.png # Dark wordmark + ochre X — top nav +│ ├── vaimo-logo-white.svg # White logo — sign-in cover +│ ├── vaimo-logo.svg # Legacy (white fill, keep for reference) +│ ├── vaimo-logo.webp # Legacy raster +│ ├── vaimo-logo-white.png # Legacy raster +│ └── google-mark.svg # Google sign-in button │ ├── supabase/ │ └── migrations/ @@ -75,8 +89,12 @@ project-pages/ | File | Purpose | |---|---| -| `lib/config.ts` | Parses `projectpages.config` YAML; defines `ParsedConfig`, `ParsedBranch` types | -| `lib/github.ts` | All GitHub API calls; accepts `branch` param for per-branch content fetching | -| `lib/auth.ts` | NextAuth options; matches passphrase → branch; stores `branchName` in JWT | +| `lib/config.ts` | Parses `projectpages.config` YAML; defines `ParsedConfig`, `ParsedBranch` types; carries `discoverBranches` flag | +| `lib/github.ts` | All GitHub API calls; per-branch content fetching; local-config override via `PROJECTPAGES_LOCAL_CONFIG`; auto-branch discovery when `discoverBranches: true` | +| `lib/auth.ts` | NextAuth options; per-user-group passphrase matching with `PROJECTPAGES_PASSPHRASE_` env override; optional `DEV_AUTH_BYPASS`; stores `userGroupName` + `branchName` + `accessibleBranches` in the JWT | +| `lib/markdown.ts` | Markdown pipeline (remark → rehype → highlight). Parses YAML frontmatter via `gray-matter` and returns it separately from the rendered HTML | | `lib/supabase.ts` | Comment CRUD; all queries are scoped by `(file_path, branch)` | -| `types/next-auth.d.ts` | Extends `Session` with `branchName: string` | +| `components/FileView/Frontmatter.tsx` | Renders parsed frontmatter as the "At a glance" block | +| `components/FileView/MermaidBlock.tsx` | Mermaid rendering + fullscreen zoom overlay + per-participant colouring | +| `components/BranchSwitcher.tsx` | Searchable dropdown of accessible branches | +| `types/next-auth.d.ts` | Extends `Session` with `branchName`, `userGroupName`, `accessibleBranches` |