Gitfed
bastien-mrq/gitfed / cmd / gitfed-web / render.go
package main

import (
	"bytes"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"path"
	"strings"
	"unicode"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/text"

	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/i18n"
	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/version"
)

// commonFuncs is shared by every page template so any of them can call
// {{t $.Lang "some.key"}} — see internal/i18n. Bound once at Parse time via
// newTpl rather than per-request, since the language is passed as an
// explicit argument instead of captured in a closure.
var commonFuncs = template.FuncMap{
	"t":          func(lang, key string, args ...any) string { return i18n.T(i18n.Lang(lang), key, args...) },
	"icon":       icon,
	"fileIcon":   fileIcon,
	"roleLabel":  func(lang, role string) string { return roleLabel(i18n.Lang(lang), role) },
	"localUser":  localUser,
	"humanBytes": humanBytes,
}

// localUser returns the bare username if principal ("user@domain") belongs
// to this instance's own domain, or "" if it's a federated principal from
// elsewhere — only local accounts have a /u/{username} profile page here.
func localUser(principal, domain string) string {
	suffix := "@" + domain
	if !strings.HasSuffix(principal, suffix) {
		return ""
	}
	return strings.TrimSuffix(principal, suffix)
}

func newTpl(name, src string) *template.Template {
	return template.Must(template.New(name).Funcs(commonFuncs).Parse(src))
}

var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM))

// renderMarkdown converts src to HTML with no link rewriting — for content
// that isn't rooted in a specific repo path (currently just the changelog).
// goldmark escapes any raw HTML found in the source by default (we never
// enable html.WithUnsafe) — README/LICENSE content comes from whoever can
// push to the repo, not necessarily someone the reader trusts, so treat it
// as untrusted input.
func renderMarkdown(src string) (template.HTML, error) {
	var buf bytes.Buffer
	if err := markdown.Convert([]byte(src), &buf); err != nil {
		return "", err
	}
	return template.HTML(buf.String()), nil
}

// renderRepoMarkdown converts src to HTML the same way renderMarkdown does,
// but additionally rewrites every relative link/image so it resolves inside
// gitfed's own repo browser instead of against the current page URL — a
// plain "docs/HOW_IT_WORKS.md" link in a rendered README would otherwise
// resolve relative to /r/{repo} in the browser and 404, since gitfed's repo
// pages aren't a real directory hierarchy the way raw files on disk are.
// dir is the directory (repo-relative, no leading/trailing slash, "" for
// root) that src itself lives in, used to resolve "../" and sibling links.
func renderRepoMarkdown(repo, dir, src string) (template.HTML, error) {
	reader := text.NewReader([]byte(src))
	doc := markdown.Parser().Parse(reader)
	ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		switch link := n.(type) {
		case *ast.Link:
			link.Destination = []byte(resolveRepoLink(repo, dir, string(link.Destination)))
		case *ast.Image:
			// Images go to /repo-raw (actual file bytes with an image
			// content-type), not /repo-blob (an HTML page) — an <img>
			// pointing at the blob page would never display.
			link.Destination = []byte(resolveRepoImage(repo, dir, string(link.Destination)))
		}
		return ast.WalkContinue, nil
	})
	var buf bytes.Buffer
	if err := markdown.Renderer().Render(&buf, []byte(src), doc); err != nil {
		return "", err
	}
	return template.HTML(buf.String()), nil
}

// resolveRepoLink rewrites a markdown link destination found in a file at
// dir (repo-relative) so it points at gitfed's own /repo-blob route instead
// of a bare relative path. Absolute URLs, mailto:, protocol-relative and
// pure same-page fragments are left untouched.
func resolveRepoLink(repo, dir, dest string) string {
	return resolveRepoDest("/repo-blob/", repo, dir, dest)
}

// resolveRepoImage is resolveRepoLink for image destinations — same
// resolution rules, but targeting /repo-raw so the browser gets the file's
// actual bytes instead of a repo-browser HTML page.
func resolveRepoImage(repo, dir, dest string) string {
	return resolveRepoDest("/repo-raw/", repo, dir, dest)
}

func resolveRepoDest(route, repo, dir, dest string) string {
	if dest == "" || strings.HasPrefix(dest, "#") || strings.HasPrefix(dest, "//") ||
		strings.HasPrefix(dest, "mailto:") || strings.Contains(dest, "://") {
		return dest
	}

	target, fragment := dest, ""
	if i := strings.IndexByte(target, '#'); i >= 0 {
		target, fragment = target[:i], target[i:]
	}
	if target == "" {
		return dest
	}

	if strings.HasPrefix(target, "/") {
		target = strings.TrimPrefix(target, "/")
	} else {
		target = path.Join(dir, target)
	}
	target = path.Clean(target)
	if target == "." || target == "" {
		return "/r/" + repo + fragment
	}
	// Left un-percent-encoded here, same as every other "?path=" link this
	// app builds via templates (e.g. repoTpl's {{.FullPath}}) — goldmark's
	// own HTML renderer still escapes the destination for the href
	// attribute, it just doesn't percent-encode "/", which is what we want.
	return route + repo + "?path=" + target + fragment
}

// brandMark is the "Branch Blocks" logo — three square-cornered rectangles
// forming a git fork, no gradient or curve. Used inline in the header (via
// currentColor, so it follows the link color) and, percent-encoded, as the
// favicon below.
const brandMark = `<svg class="brand-mark" width="18" height="18" viewBox="0 0 96 96" aria-hidden="true"><rect x="41" y="46" width="14" height="36" fill="currentColor"/><rect x="14" y="14" width="14" height="34" fill="currentColor" transform="rotate(35 21 31)"/><rect x="68" y="14" width="14" height="34" fill="currentColor" transform="rotate(-35 75 31)"/></svg>`

// iconSprite is a single SVG <symbol> sheet, injected once right after
// <body>, that every page and every icon="ic-*" <use> in the app draws
// from — the line-icon set that replaces emoji throughout the UI, drawn
// with the same square-cornered vocabulary as brandMark rather than a
// generic icon font.
const iconSprite = `<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ic-folder" viewBox="0 0 24 24"><path d="M3 6.2c0-.66.54-1.2 1.2-1.2h4l1.6 1.8h7c.66 0 1.2.54 1.2 1.2v8.6c0 .66-.54 1.2-1.2 1.2H4.2c-.66 0-1.2-.54-1.2-1.2V6.2Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol>
<symbol id="ic-file" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol>
<symbol id="ic-file-code" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9.5 12.3l-2.2 2.2 2.2 2.2M14.5 12.3l2.2 2.2-2.2 2.2" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="ic-file-doc" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8 12.3h8M8 15h8M8 17.7h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></symbol>
<symbol id="ic-file-image" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><rect x="7.5" y="11.6" width="9" height="6.4" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="9.8" cy="13.9" r="0.9" fill="currentColor"/><path d="M8 17.2l2.6-2.6 2 2 2.9-2.9 1 1" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></symbol>
<symbol id="ic-file-config" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><circle cx="12" cy="15" r="2.1" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M12 11.8v1M12 17.2v1M9.2 15h1M14.8 15h1M10 13l.7.7M13.3 16.3l.7.7M14 13l-.7.7M10.7 16.3l-.7.7" stroke="currentColor" stroke-width="1" stroke-linecap="round"/></symbol>
<symbol id="ic-branch" viewBox="0 0 24 24"><rect x="10.5" y="12" width="3" height="8.5" fill="currentColor"/><rect x="3.3" y="3" width="3" height="8" fill="currentColor" transform="rotate(35 4.8 7)"/><rect x="16.7" y="3" width="3" height="8" fill="currentColor" transform="rotate(-35 18.2 7)"/></symbol>
<symbol id="ic-copy" viewBox="0 0 24 24"><rect x="8.5" y="8.5" width="10.5" height="12.5" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M5.5 15V4.6c0-.6.48-1.1 1.1-1.1H16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="ic-check" viewBox="0 0 24 24"><path d="M4.5 12.5l4.6 4.6L19.5 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="ic-settings" viewBox="0 0 24 24"><path d="M18.92 10.02 21.65 10.3v3.4l-2.73.28-.62 1.51 1.73 2.13-2.41 2.41-2.13-1.73-1.51.62-.28 2.73h-3.4l-.28-2.73-1.51-.62-2.13 1.73-2.41-2.41 1.73-2.13-.62-1.51-2.73-.28v-3.4l2.73-.28.62-1.51-1.73-2.13 2.41-2.41 2.13 1.73 1.51-.62.28-2.73h3.4l.28 2.73 1.51.62 2.13-1.73 2.41 2.41-1.73 2.13.62 1.51Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol>
<symbol id="ic-shield" viewBox="0 0 24 24"><path d="M12 3.4 19 6v5.6c0 4.7-3 7.9-7 9-4-1.1-7-4.3-7-9V6l7-2.6Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9 12.2l2 2 4-4.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="ic-logout" viewBox="0 0 24 24"><path d="M10.5 4.5H6.2C5.5 4.5 5 5 5 5.7v12.6c0 .66.54 1.2 1.2 1.2h4.3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.5 8.2 18.3 12l-3.8 3.8M18.3 12H9.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="ic-lock" viewBox="0 0 24 24"><rect x="5.5" y="10.5" width="13" height="10" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 10.5V7.8a4 4 0 0 1 8 0v2.7" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol>
<symbol id="ic-key" viewBox="0 0 24 24"><circle cx="8" cy="15" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11.3 12 20 3.3M17 6.3l2 2M14 9.3l2 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="ic-network" viewBox="0 0 24 24"><circle cx="12" cy="4.6" r="2.1" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5" cy="18" r="2.1" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="19" cy="18" r="2.1" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 6.7v4.3M10.4 12.5 6.3 16M13.6 12.5 17.7 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="ic-sliders" viewBox="0 0 24 24"><path d="M5 6h9M17.5 6H19M5 12h5.5M13 12H19M5 18h9M17.5 18H19" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="12" cy="6" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="9.5" cy="12" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="15.5" cy="18" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol>
<symbol id="ic-arrow" viewBox="0 0 24 24"><path d="M4 12h14.5M13 6.5l6 5.5-6 5.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="ic-close" viewBox="0 0 24 24"><path d="M5.5 5.5l13 13M18.5 5.5l-13 13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="ic-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="7" fill="none" stroke="currentColor" stroke-width="1.6"/><line x1="15.8" y1="15.8" x2="20.5" y2="20.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="ic-bell" viewBox="0 0 24 24"><path d="M6 10.5c0-3.3 2.7-6 6-6s6 2.7 6 6v3.3l1.6 2.7H4.4L6 13.8V10.5Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9.8 18.5a2.3 2.3 0 0 0 4.4 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="ic-history" viewBox="0 0 24 24"><circle cx="12" cy="12.5" r="8" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 8v4.7l3.3 2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 3.3 5 6M16 3.3 19 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="ic-user" viewBox="0 0 24 24"><circle cx="12" cy="8.3" r="3.3" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M4.8 19c1-3.2 3.9-5 7.2-5s6.2 1.8 7.2 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="ic-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
<symbol id="ic-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3v2.3M12 18.7V21M21 12h-2.3M5.3 12H3M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6M18.4 18.4l-1.6-1.6M7.2 7.2 5.6 5.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
<symbol id="ic-moon" viewBox="0 0 24 24"><path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
<symbol id="ic-auto" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 4a8 8 0 0 1 0 16Z" fill="currentColor"/></symbol>
<symbol id="ic-download" viewBox="0 0 24 24"><path d="M12 4v11M7.5 11.5 12 16l4.5-4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 18.5h14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol>
</defs>
</svg>`

// icon renders a use of one of iconSprite's symbols, sized to sit inline
// with text (buttons, table cells, menu items).
func icon(name string) template.HTML {
	return template.HTML(`<svg class="icon" aria-hidden="true"><use href="#ic-` + name + `"/></svg>`)
}

// humanBytes formats a byte count the way a file manager would (1 decimal
// place past KiB, binary/1024-based units) — for the admin repos page's
// disk usage column.
func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for m := n / unit; m >= unit; m /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

var (
	codeExtensions = map[string]bool{
		".go": true, ".js": true, ".mjs": true, ".ts": true, ".jsx": true, ".tsx": true,
		".py": true, ".rb": true, ".java": true, ".c": true, ".h": true, ".cpp": true, ".cc": true, ".hpp": true,
		".rs": true, ".php": true, ".sh": true, ".bash": true, ".css": true, ".scss": true,
		".html": true, ".htm": true, ".sql": true, ".lua": true, ".swift": true, ".kt": true, ".pl": true,
	}
	docExtensions   = map[string]bool{".md": true, ".markdown": true, ".txt": true, ".rst": true, ".adoc": true}
	imageExtensions = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, ".bmp": true, ".ico": true}

	// imageMIME gives /repo-raw a browser-renderable content-type for the
	// same set of extensions imageExtensions recognizes — everything else
	// that route serves is text/plain or octet-stream, never sniffed
	// (X-Content-Type-Options: nosniff is set globally).
	imageMIME = map[string]string{
		".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
		".svg": "image/svg+xml", ".webp": "image/webp", ".bmp": "image/bmp", ".ico": "image/x-icon",
	}
	configExtensions = map[string]bool{".json": true, ".yaml": true, ".yml": true, ".toml": true, ".xml": true, ".ini": true, ".conf": true, ".env": true, ".lock": true}

	// docFilenames catches conventional extensionless files that are
	// clearly documentation, not code, even though they have no matching
	// extension above.
	docFilenames = map[string]bool{"README": true, "LICENSE": true, "CHANGELOG": true, "AUTHORS": true, "NOTICE": true}
)

// fileIcon maps a file's name to one of iconSprite's "file-*" symbols
// (falling back to the plain "file" glyph for anything unrecognized) —
// extension-based, same lightweight heuristic spirit as the license
// detection in ROADMAP.md §3, not a real MIME/language sniffer.
func fileIcon(name string) string {
	if docFilenames[strings.ToUpper(name)] {
		return "file-doc"
	}
	switch ext := strings.ToLower(path.Ext(name)); {
	case codeExtensions[ext]:
		return "file-code"
	case docExtensions[ext]:
		return "file-doc"
	case imageExtensions[ext]:
		return "file-image"
	case configExtensions[ext]:
		return "file-config"
	default:
		return "file"
	}
}

// roleLabel translates an ACL role (or the synthetic "owner") for display.
func roleLabel(lang i18n.Lang, role string) string {
	switch role {
	case "owner", "read", "write", "admin":
		return i18n.T(lang, "role."+role)
	default:
		return role
	}
}

const shellHeadSrc = `<!doctype html>
<html{{if .Theme}} data-theme="{{.Theme}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} — {{end}}Gitfed</title>
<link rel="icon" media="(prefers-color-scheme: light)" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect x='41' y='46' width='14' height='36' fill='%2317181a'/%3E%3Crect x='14' y='14' width='14' height='34' fill='%2317181a' transform='rotate(35 21 31)'/%3E%3Crect x='68' y='14' width='14' height='34' fill='%2317181a' transform='rotate(-35 75 31)'/%3E%3C/svg%3E">
<link rel="icon" media="(prefers-color-scheme: dark)" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect x='41' y='46' width='14' height='36' fill='%23f0efec'/%3E%3Crect x='14' y='14' width='14' height='34' fill='%23f0efec' transform='rotate(35 21 31)'/%3E%3Crect x='68' y='14' width='14' height='34' fill='%23f0efec' transform='rotate(-35 75 31)'/%3E%3C/svg%3E">
<style>
  :root {
    --canvas: #0d0f13; --surface: #161a21; --surface-2: #1c212a; --surface-3: #232933;
    --border: #262c36; --border-strong: #333a46;
    --text: #e8eaed; --text-dim: #9aa1ac; --text-faint: #6b7280;
    --accent: #6c9df5; --accent-dim: #3a5a8f; --accent-ink: #0d0f13;
    --ok-bg: rgba(95,191,143,0.14); --ok-fg: #7bd6a8;
    --pending-bg: rgba(224,179,78,0.14); --pending-fg: #e0b34e;
    --danger-bg: rgba(242,139,130,0.14); --danger-fg: #f28b82;
    --violet-bg: rgba(167,139,250,0.14); --violet-fg: #b9a3f7;
    --mono: "SF Mono", "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
  }
  /* Light theme: automatic (OS preference) ... */
  @media (prefers-color-scheme: light) {
    :root {
      --canvas: #f4f5f7; --surface: #ffffff; --surface-2: #edeff2; --surface-3: #e1e4e9;
      --border: #dde1e6; --border-strong: #c5cbd3;
      --text: #1a1d23; --text-dim: #565d6b; --text-faint: #868fa0;
      --accent: #3f6fd1; --accent-dim: #d7e3fb; --accent-ink: #ffffff;
      --ok-bg: rgba(30,122,76,0.12); --ok-fg: #1e7a4c;
      --pending-bg: rgba(150,101,10,0.12); --pending-fg: #96650a;
      --danger-bg: rgba(178,58,46,0.12); --danger-fg: #b23a2e;
      --violet-bg: rgba(104,66,194,0.12); --violet-fg: #6842c2;
    }
  }
  /* ... and forced, via the nav switcher (see theme.go) — these win over
     the media query above (and over each other vs. the base :root) purely
     by selector specificity, regardless of source order. */
  :root[data-theme="light"] {
    --canvas: #f4f5f7; --surface: #ffffff; --surface-2: #edeff2; --surface-3: #e1e4e9;
    --border: #dde1e6; --border-strong: #c5cbd3;
    --text: #1a1d23; --text-dim: #565d6b; --text-faint: #868fa0;
    --accent: #3f6fd1; --accent-dim: #d7e3fb; --accent-ink: #ffffff;
    --ok-bg: rgba(30,122,76,0.12); --ok-fg: #1e7a4c;
    --pending-bg: rgba(150,101,10,0.12); --pending-fg: #96650a;
    --danger-bg: rgba(178,58,46,0.12); --danger-fg: #b23a2e;
    --violet-bg: rgba(104,66,194,0.12); --violet-fg: #6842c2;
  }
  :root[data-theme="dark"] {
    --canvas: #0d0f13; --surface: #161a21; --surface-2: #1c212a; --surface-3: #232933;
    --border: #262c36; --border-strong: #333a46;
    --text: #e8eaed; --text-dim: #9aa1ac; --text-faint: #6b7280;
    --accent: #6c9df5; --accent-dim: #3a5a8f; --accent-ink: #0d0f13;
    --ok-bg: rgba(95,191,143,0.14); --ok-fg: #7bd6a8;
    --pending-bg: rgba(224,179,78,0.14); --pending-fg: #e0b34e;
    --danger-bg: rgba(242,139,130,0.14); --danger-fg: #f28b82;
    --violet-bg: rgba(167,139,250,0.14); --violet-fg: #b9a3f7;
  }
  * { box-sizing: border-box; }
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; background: var(--canvas); color: var(--text); font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
  a { color: inherit; }

  /* ---------- nav ---------- */
  .gf-nav { display: flex; align-items: center; gap: 0.5rem; height: 52px; padding: 0 1rem; background: var(--surface); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
  .gf-nav-brand { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--accent); text-decoration: none; font-weight: 700; font-size: 0.95rem; flex-shrink: 0; }
  .brand-mark { flex-shrink: 0; }
  .gf-nav-burger { display: none; background: none; border: none; color: var(--text-dim); padding: 0.4rem; cursor: pointer; margin-left: auto; }
  .gf-nav-panel { display: flex; align-items: center; gap: 0.75rem; flex: 1; margin-left: 0.75rem; min-width: 0; }
  .gf-nav-links { display: flex; align-items: center; gap: 0.15rem; flex-shrink: 0; }
  .gf-nav-links a { color: var(--text-dim); text-decoration: none; font-size: 0.86rem; padding: 0.4rem 0.6rem; border-radius: 6px; }
  .gf-nav-links a:hover { color: var(--text); }
  .gf-nav-links a.active { color: var(--text); background: var(--surface-2); font-weight: 600; }
  .gf-nav-search { flex: 1; display: flex; justify-content: center; margin: 0; }
  .gf-nav-search-inner { display: flex; align-items: center; gap: 0.5rem; width: 100%; max-width: 340px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 0.38rem 0.65rem; color: var(--text-faint); }
  .gf-nav-search-inner:focus-within { border-color: var(--accent); }
  .gf-nav-search input { border: none; background: none; color: var(--text); font-size: 0.84rem; padding: 0; margin: 0; flex: 1; min-width: 0; }
  .gf-nav-search input:focus { outline: none; }
  .gf-nav-search input::placeholder { color: var(--text-faint); }
  .gf-nav-search kbd { font-family: var(--mono); font-size: 0.66rem; background: var(--surface-3); border: 1px solid var(--border-strong); border-radius: 4px; padding: 0.05rem 0.35rem; color: var(--text-faint); flex-shrink: 0; }
  .gf-nav-right { display: flex; align-items: center; gap: 0.6rem; flex-shrink: 0; }
  .gf-nav-right a { color: var(--text-dim); text-decoration: none; font-size: 0.86rem; }
  .gf-nav-right a:hover { color: var(--text); }
  .gf-nav-right .gf-btn { margin-top: 0; }
  .gf-nav-right .gf-btn.primary:hover { color: var(--accent-ink); }
  .linklike { background: none; border: none; color: var(--text-dim); font-size: 0.86rem; cursor: pointer; padding: 0; font-family: inherit; }
  .linklike:hover { color: var(--text); }
  .icon { width: 15px; height: 15px; flex-shrink: 0; vertical-align: -0.15em; }

  /* --- language switcher --- */
  .gf-lang { display: flex; align-items: center; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 2px; font-size: 0.72rem; font-weight: 700; flex-shrink: 0; }
  .gf-lang a { display: block; color: var(--text-faint); text-decoration: none; padding: 0.3rem 0.55rem; border-radius: 999px; letter-spacing: 0.02em; }
  .gf-lang a.active { background: var(--accent); color: var(--accent-ink); }

  /* --- notification bell --- */
  .gf-bell { position: relative; display: flex; align-items: center; color: var(--text-dim); flex-shrink: 0; padding: 0.3rem; border-radius: 7px; }
  .gf-bell:hover { color: var(--text); background: var(--surface-2); }
  .gf-bell-badge {
    position: absolute; top: -2px; right: -2px; min-width: 15px; height: 15px; padding: 0 3px; border-radius: 999px;
    background: var(--danger-fg); color: var(--canvas); font-size: 0.62rem; font-weight: 700; line-height: 15px; text-align: center;
  }

  /* --- profile dropdown --- */
  .gf-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-dim); color: var(--text); display: flex; align-items: center; justify-content: center; font-size: 0.68rem; font-weight: 700; flex-shrink: 0; }
  .gf-profile { position: relative; flex-shrink: 0; }
  .gf-profile-trigger { display: flex; align-items: center; gap: 0.5rem; background: none; border: none; color: var(--text); cursor: pointer; padding: 0.3rem 0.4rem; margin: 0; border-radius: 7px; font-size: 0.86rem; font-family: inherit; font-weight: 600; }
  .gf-profile-trigger:hover { background: var(--surface-2); }
  .gf-profile-trigger .chev { color: var(--text-faint); font-size: 0.65rem; transition: transform 0.12s; }
  .gf-profile-trigger[aria-expanded="true"] .chev { transform: rotate(180deg); }
  .gf-profile-menu {
    position: absolute; top: calc(100% + 6px); right: 0; min-width: 190px; background: var(--surface-2);
    border: 1px solid var(--border-strong); border-radius: 9px; padding: 0.4rem;
    box-shadow: 0 10px 24px rgba(0,0,0,0.4); display: none; flex-direction: column; gap: 0.1rem; z-index: 20;
  }
  .gf-profile-menu.open { display: flex; }
  .gf-profile-menu .who { padding: 0.5rem 0.7rem 0.4rem; font-size: 0.78rem; color: var(--text-faint); border-bottom: 1px solid var(--border); margin-bottom: 0.3rem; }
  .gf-profile-menu a, .gf-profile-menu button {
    display: flex; align-items: center; gap: 0.55rem; padding: 0.5rem 0.7rem; border-radius: 6px; font-size: 0.86rem;
    color: var(--text); background: none; border: none; text-align: left; width: 100%; cursor: pointer; font-family: inherit; margin: 0;
  }
  .gf-profile-menu a:hover, .gf-profile-menu button:hover { background: var(--surface-3); }
  .gf-profile-menu .divider { height: 1px; background: var(--border); margin: 0.3rem 0; }
  .gf-profile-menu .danger { color: var(--danger-fg); }

  @media (max-width: 720px) {
    .gf-nav-burger { display: flex; align-items: center; }
    .gf-nav-panel { display: none; position: absolute; top: 52px; left: 0; right: 0; background: var(--surface); border-bottom: 1px solid var(--border); flex-direction: column; align-items: stretch; gap: 0.75rem; padding: 0.85rem 1rem; margin: 0; }
    .gf-nav-panel.open { display: flex; }
    .gf-nav-links { flex-direction: column; align-items: stretch; gap: 0.15rem; }
    .gf-nav-search-inner { max-width: none; }
    .gf-nav-right { justify-content: space-between; }
  }

  /* ---------- layout ---------- */
  main { padding: 1.5rem 1rem; max-width: 980px; margin: 0 auto; }
  section { margin-bottom: 2rem; }

  /* ---------- landing page ---------- */
  .hero { padding: 2.6rem 0 2rem; text-align: center; }
  .hero .kicker { display: inline-flex; align-items: center; gap: 0.45rem; font-family: var(--mono); font-size: 0.76rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); background: rgba(108,157,245,0.1); border: 1px solid rgba(108,157,245,0.25); padding: 0.3rem 0.7rem; border-radius: 999px; margin-bottom: 1.4rem; }
  .hero h1 { font-size: clamp(1.8rem, 4.4vw, 2.5rem); line-height: 1.14; margin: 0 auto 1rem; max-width: 640px; font-weight: 700; letter-spacing: -0.01em; }
  .hero h1 em { color: var(--accent); font-style: normal; }
  .hero p.lead { color: var(--text-dim); font-size: 1.02rem; max-width: 540px; margin: 0 auto 1.7rem; }
  .hero-actions { display: flex; gap: 0.7rem; justify-content: center; flex-wrap: wrap; }
  .btn { display: inline-flex; align-items: center; gap: 0.5rem; font-family: inherit; font-size: 0.9rem; font-weight: 600; padding: 0.65rem 1.15rem; border-radius: 8px; text-decoration: none; cursor: pointer; border: 1px solid transparent; margin-top: 0; }
  .btn-primary { background: var(--accent); color: var(--accent-ink); }
  .btn-primary:hover { filter: brightness(1.08); }
  .btn-secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); }

  .pillars { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; margin: 2.2rem 0; }
  .pillar { background: var(--surface); padding: 1.5rem 1.4rem; }
  .pillar .picon { margin-bottom: 0.8rem; color: var(--accent); }
  .pillar .picon .icon { width: 24px; height: 24px; }
  .pillar h3 { font-size: 1rem; margin: 0 0 0.5rem; }
  .pillar p { color: var(--text-dim); font-size: 0.88rem; margin: 0; }

  .split { display: grid; grid-template-columns: 1.1fr 1fr; gap: 2.2rem; align-items: center; margin: 2.8rem 0; }
  .split h2 { font-size: 1.3rem; margin: 0 0 0.8rem; }
  .split p { color: var(--text-dim); font-size: 0.92rem; margin: 0 0 0.9rem; }
  .split .links { display: flex; gap: 1.3rem; flex-wrap: wrap; }
  .split .links a { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--accent); text-decoration: none; font-size: 0.88rem; font-weight: 600; }

  .fed-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.4rem; }
  .fed-diagram svg { width: 100%; height: auto; display: block; }

  .changelog-cta { text-align: center; margin: 2.2rem 0; }

  .cta-band { text-align: center; padding: 2.4rem 1.4rem; margin: 2.6rem 0 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; }
  .cta-band h2 { font-size: 1.25rem; margin: 0 0 0.5rem; }
  .cta-band p { color: var(--text-dim); font-size: 0.9rem; margin: 0 0 1.2rem; }

  @media (max-width: 720px) {
    .pillars, .split { grid-template-columns: 1fr; }
  }

  /* ---------- security page ---------- */
  .sec-head { padding: 1.8rem 0 0.6rem; text-align: center; }
  .sec-head .kicker { display: inline-flex; align-items: center; gap: 0.45rem; font-family: var(--mono); font-size: 0.76rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ok-fg); background: var(--ok-bg); border: 1px solid rgba(123,214,168,0.3); padding: 0.3rem 0.7rem; border-radius: 999px; margin-bottom: 1.1rem; }
  .sec-head h1 { font-size: clamp(1.6rem, 3.6vw, 2.1rem); margin: 0 auto 0.7rem; max-width: 620px; }
  .sec-head p { color: var(--text-dim); max-width: 540px; margin: 0 auto; font-size: 0.95rem; }

  .sec-section { margin: 2.6rem 0; padding-top: 2rem; border-top: 1px solid var(--border); }
  .sec-section:first-of-type { border-top: none; }
  .sec-section .tag { font-family: var(--mono); font-size: 0.72rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
  .sec-section h2 { font-size: 1.15rem; margin: 0 0 0.7rem; display: flex; align-items: center; gap: 0.55rem; }
  .sec-section h2 .icon { width: 19px; height: 19px; color: var(--accent); }
  .sec-section > p { color: var(--text-dim); font-size: 0.92rem; max-width: 640px; margin: 0 0 1.2rem; }
  .sec-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.4rem; margin-bottom: 1rem; overflow-x: auto; }
  .sec-diagram svg { display: block; margin: 0 auto; min-width: 380px; }
  .sec-diagram .cap { text-align: center; color: var(--text-faint); font-size: 0.78rem; margin-top: 0.8rem; }
  .sec-facts { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.7rem; margin-top: 1rem; }
  .sec-fact { background: var(--surface-2); border: 1px solid var(--border); border-radius: 9px; padding: 0.85rem 1rem; font-size: 0.86rem; color: var(--text-dim); display: flex; gap: 0.6rem; }
  .sec-fact .icon { width: 15px; height: 15px; color: var(--ok-fg); flex-shrink: 0; margin-top: 0.15rem; }
  .sec-fact strong { color: var(--text); font-weight: 600; }
  @media (max-width: 640px) { .sec-facts { grid-template-columns: 1fr; } }

  /* ---------- repo page ---------- */
  .gf-crumbs { font-family: var(--mono); font-size: 0.84rem; color: var(--text-dim); }
  .gf-crumbs a { color: var(--text-dim); text-decoration: none; }
  .gf-crumbs a:hover { color: var(--text); }
  .gf-crumbs .sep { color: var(--text-faint); margin: 0 0.35em; }
  .gf-repo-head { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; margin-top: 0.6rem; }
  .gf-repo-head h1 { font-size: 1.3rem; margin: 0; font-weight: 700; }
  .gf-repo-head .count { font-family: var(--mono); font-size: 0.82rem; color: var(--text-faint); }
  .gf-actions-row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin: 1rem 0 0.7rem; }
  .gf-actions-row .gf-btn { margin-top: 0; }
  .gf-actions-right { margin-left: auto; display: flex; align-items: center; gap: 0.4rem; }
  .gf-btn.subtle { background: transparent; border-color: transparent; color: var(--text-dim); font-weight: 500; }
  .gf-btn.subtle:hover { background: var(--surface-2); color: var(--text); }
  .gf-pager { display: flex; align-items: center; justify-content: center; gap: 1rem; margin: 1rem 0; }
  .gf-pager .gf-btn { margin-top: 0; }
  .gf-pager .gf-btn.disabled { opacity: 0.4; pointer-events: none; }
  .gf-branch-dropdown { position: relative; }
  .gf-branch-dropdown > summary { list-style: none; }
  .gf-branch-dropdown > summary::-webkit-details-marker { display: none; }
  .gf-branch-dropdown[open] > summary { background: var(--surface-3); }
  .gf-branch-menu {
    position: absolute; top: calc(100% + 6px); left: 0; min-width: 180px; max-height: 280px; overflow-y: auto;
    background: var(--surface-2); border: 1px solid var(--border-strong); border-radius: 9px; padding: 0.4rem;
    box-shadow: 0 10px 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; gap: 0.1rem; z-index: 20;
  }
  .gf-branch-menu a {
    display: block; padding: 0.4rem 0.6rem; border-radius: 6px; font-size: 0.84rem; font-family: var(--mono);
    color: var(--text-dim); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .gf-branch-menu a:hover { background: var(--surface-3); color: var(--text); }
  .gf-branch-menu a.active { color: var(--accent); font-weight: 600; }

  .gf-clone-block { background: var(--surface-2); border: 1px solid var(--border); border-radius: 9px; overflow: hidden; margin-bottom: 1.25rem; }
  .gf-clone-block .gf-tabs.gf-clone-toggle { display: flex; gap: 0.2rem; padding: 0.3rem; border-bottom: 1px solid var(--border); margin-bottom: 0; }
  .gf-clone-block .gf-clone-toggle button { font-family: var(--mono); font-size: 0.76rem; font-weight: 700; letter-spacing: 0.03em; padding: 0.3rem 0.7rem; border-radius: 6px; border-bottom: none; margin-bottom: 0; color: var(--text-faint); }
  .gf-clone-block .gf-clone-toggle button.active { background: var(--surface-3); color: var(--text); border-bottom-color: transparent; }
  .gf-clone-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; font-family: var(--mono); font-size: 0.8rem; color: var(--text-dim); }
  .gf-clone-block .gf-tabpane.gf-clone-row { display: none; }
  .gf-clone-block .gf-tabpane.gf-clone-row.active { display: flex; }
  .gf-clone-row code { background: none; padding: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
  .gf-clone-row .label { flex-shrink: 0; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.03em; color: var(--text-faint); }
  .gf-clone-row button.linklike { flex-shrink: 0; font-size: 0.95rem; }
  .gf-clone-block.single .gf-clone-row { padding: 0.6rem 0.9rem; }
  .gf-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 1.25rem; }
  .gf-file-table { display: table; width: 100%; border-collapse: collapse; margin: 0; }
  .gf-file-table tr { border-bottom: 1px solid var(--border); }
  .gf-file-table tr:last-child { border-bottom: none; }
  .gf-file-table td { padding: 0.55rem 0.9rem; font-size: 0.86rem; vertical-align: middle; border: none; }
  .gf-file-table td.icon { width: 1.4rem; padding-right: 0; }
  .gf-file-table td.name { font-family: var(--mono); font-size: 0.84rem; }
  .gf-file-table td.name a { color: var(--text); text-decoration: none; }
  .gf-file-table td.name a:hover { color: var(--accent); text-decoration: underline; }
  .gf-file-table td.meta { color: var(--text-faint); font-size: 0.78rem; text-align: right; white-space: nowrap; }
  .gf-readme-head { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.1rem; border-bottom: 1px solid var(--border); font-size: 0.86rem; color: var(--text-dim); font-family: var(--mono); }
  .gf-readme-langs { margin-left: auto; display: flex; gap: 0.35rem; }
  .gf-readme-body { padding: 1.3rem; }
  .gf-license-card { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.85rem 1.1rem; font-size: 0.86rem; color: var(--text-dim); font-family: var(--mono); }

  @media (max-width: 620px) {
    .gf-action-bar { flex-direction: column; align-items: stretch; }
    .gf-clone-url { order: 2; }
    .gf-file-table td.meta { display: none; }
  }

  /* ---------- dashboard / settings / admin ---------- */
  .gf-page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
  .gf-page-head h1, .gf-page-head h2 { font-size: 1.35rem; margin: 0; }
  .gf-page-head .count { font-family: var(--mono); font-size: 0.82rem; color: var(--text-faint); }

  .gf-stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 1.25rem; }
  .gf-stat { background: var(--surface); padding: 1rem 1.1rem; }
  .gf-stat .n { font-size: 1.5rem; font-weight: 700; }
  .gf-stat .l { font-size: 0.78rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.03em; }

  .gf-repo-list { display: flex; flex-direction: column; }
  .gf-repo-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--border); }
  .gf-repo-row:last-child { border-bottom: none; }
  .gf-repo-row:hover { background: var(--surface-2); }
  .gf-repo-icon { width: 30px; height: 30px; border-radius: 7px; background: var(--surface-2); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
  .gf-repo-main { flex: 1; min-width: 0; }
  .gf-repo-main .name { font-family: var(--mono); font-size: 0.92rem; font-weight: 600; }
  .gf-repo-main .name a:hover { color: var(--accent); }
  .gf-repo-main .meta { font-size: 0.78rem; color: var(--text-faint); margin-top: 0.15rem; }
  .gf-repo-main .owner { color: var(--text-faint); font-weight: 400; }
  .gf-repo-main .topics { margin-top: 0.35rem; display: flex; gap: 0.35rem; flex-wrap: wrap; }
  .gf-repo-main .topics a { font-family: var(--mono); font-size: 0.72rem; padding: 0.1rem 0.5rem; border-radius: 999px; background: var(--surface-3); color: var(--text-faint); text-decoration: none; }
  .gf-repo-main .topics a:hover { color: var(--text-dim); }

  .gf-commit-list { display: flex; flex-direction: column; }
  .gf-commit-row { display: flex; align-items: center; gap: 0.7rem; padding: 0.45rem 1.1rem; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit; }
  .gf-commit-row:last-child { border-bottom: none; }
  .gf-commit-row:hover { background: var(--surface-2); }
  .gf-commit-main { flex: 1; min-width: 0; }
  .gf-commit-main .subject { font-size: 0.88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .gf-commit-main .meta { font-size: 0.75rem; color: var(--text-faint); margin-top: 0.1rem; }
  .gf-commit-hash { flex-shrink: 0; font-size: 0.78rem; color: var(--text-dim); background: var(--surface-2); padding: 0.15rem 0.5rem; border-radius: 5px; }

  .gf-commit-detail-head { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--border); }
  .gf-commit-detail-head .subject { font-size: 1.08rem; font-weight: 650; margin: 0 0 0.5rem; }
  .gf-commit-detail-head .body { white-space: pre-wrap; color: var(--text-dim); font-size: 0.87rem; margin: 0 0 0.9rem; }
  .gf-commit-detail-head .meta-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1.2rem; font-size: 0.82rem; color: var(--text-faint); }
  .gf-commit-detail-head code.hash { font-family: var(--mono); background: var(--surface-2); padding: 0.15rem 0.5rem; border-radius: 5px; color: var(--text-dim); }
  .gf-commit-detail-head a:hover code.hash { color: var(--accent); }

  .gf-diffstat-head { display: flex; align-items: center; justify-content: space-between; padding: 0.8rem 1.2rem; border-bottom: 1px solid var(--border); font-size: 0.85rem; color: var(--text-dim); }
  .gf-diffstat-head .totals .add { color: var(--ok-fg); }
  .gf-diffstat-head .totals .del { color: var(--danger-fg); }
  .gf-diffstat-row { display: flex; align-items: center; gap: 0.7rem; padding: 0.55rem 1.2rem; border-bottom: 1px solid var(--border); font-size: 0.85rem; }
  .gf-diffstat-row:last-child { border-bottom: none; }
  .gf-diffstat-row:hover { background: var(--surface-2); }
  .gf-diffstat-row .path { font-family: var(--mono); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .gf-diffstat-row .stat { font-family: var(--mono); font-size: 0.78rem; color: var(--text-faint); flex-shrink: 0; }
  .gf-diffstat-row .stat .add { color: var(--ok-fg); }
  .gf-diffstat-row .stat .del { color: var(--danger-fg); }

  .gf-diff-file { border-bottom: 1px solid var(--border); }
  .gf-diff-file:last-child { border-bottom: none; }
  .gf-diff-file-head { display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 1.2rem; background: var(--surface-2); font-family: var(--mono); font-size: 0.82rem; }
  .gf-diff { font-family: var(--mono); font-size: 0.8rem; line-height: 1.55; overflow-x: auto; }
  .gf-diff .line { display: block; white-space: pre; padding: 0 1.2rem; }
  .gf-diff .line.add { background: rgba(123,214,168,0.09); color: #a8e6c4; }
  .gf-diff .line.del { background: rgba(242,139,130,0.09); color: #f5aca6; }
  .gf-diff .line.hunk { color: var(--accent); background: var(--surface-2); }
  .gf-diff .line.ctx { color: var(--text-dim); }

  /* Merge requests */
  .badge.open { background: var(--ok-bg); color: var(--ok-fg); }
  .badge.merged { background: var(--violet-bg); color: var(--violet-fg); }
  .badge.closed { background: var(--danger-bg); color: var(--danger-fg); }

  .mr-row { display: flex; align-items: flex-start; gap: 0.8rem; padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit; }
  .mr-row:last-child { border-bottom: none; }
  .mr-row:hover { background: var(--surface-2); }
  .mr-status-dot { width: 9px; height: 9px; border-radius: 50%; margin-top: 0.4rem; flex-shrink: 0; }
  .mr-status-dot.open { background: var(--ok-fg); }
  .mr-status-dot.merged { background: var(--violet-fg); }
  .mr-status-dot.closed { background: var(--danger-fg); }
  .mr-main { flex: 1; min-width: 0; }
  .mr-main .title { font-size: 0.94rem; }
  .mr-main .meta { font-size: 0.8rem; color: var(--text-faint); margin-top: 0.25rem; font-family: var(--mono); }
  .mr-main .meta .branches { color: var(--text-dim); }

  .branch-picker { display: flex; align-items: center; gap: 0.7rem; }
  .branch-picker select { flex: 1; font-family: var(--mono); }
  .branch-picker .arrow { color: var(--text-faint); display: inline-flex; }

  .mr-detail-head { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--border); }
  .mr-detail-head .titlebar { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
  .mr-detail-head .titlebar h2 { font-size: 1.08rem; margin: 0; font-weight: 650; }
  .mr-detail-head .num { color: var(--text-faint); font-weight: 400; }
  .mr-detail-head .branches { font-family: var(--mono); font-size: 0.84rem; color: var(--text-dim); display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.7rem; }
  .mr-detail-head .branches code { background: var(--surface-2); padding: 0.15rem 0.5rem; border-radius: 5px; }
  .mr-detail-head .desc { color: var(--text-dim); font-size: 0.88rem; white-space: pre-wrap; margin: 0.6rem 0 0; }
  .mr-detail-head .meta { font-size: 0.8rem; color: var(--text-faint); margin-top: 0.6rem; }

  .merge-panel { padding: 1rem 1.2rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
  .merge-panel.clean { background: var(--ok-bg); }
  .merge-panel.conflict { background: var(--danger-bg); }
  .merge-panel .msg { font-size: 0.88rem; }
  .merge-panel.clean .msg { color: var(--ok-fg); }
  .merge-panel.conflict .msg { color: var(--danger-fg); }
  .merge-panel .msg strong { display: block; margin-bottom: 0.15rem; font-size: 0.9rem; }
  .merge-panel .msg .files { font-family: var(--mono); font-size: 0.78rem; opacity: 0.85; }
  .merge-panel .msg a { text-decoration: underline; }

  .comment { display: flex; gap: 0.7rem; padding: 0.9rem 1.2rem; border-bottom: 1px solid var(--border); }
  .comment:last-child { border-bottom: none; }
  .comment .avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--surface-3); display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: 700; color: var(--text-dim); flex-shrink: 0; }
  .comment .body { flex: 1; min-width: 0; }
  .comment .head { font-size: 0.82rem; margin-bottom: 0.25rem; }
  .comment .head .author { font-weight: 600; }
  .comment .head .when { color: var(--text-faint); margin-left: 0.4rem; }
  .comment .text { font-size: 0.88rem; color: var(--text-dim); white-space: pre-wrap; }
  .comment-form { padding: 1rem 1.2rem; }
  .comment-form textarea { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 0.6rem 0.7rem; color: var(--text); font-family: inherit; font-size: 0.88rem; min-height: 70px; resize: vertical; }
  .comment-form .row { display: flex; justify-content: flex-end; margin-top: 0.6rem; }

  .gf-new-repo summary { list-style: none; cursor: pointer; }
  .gf-new-repo summary::-webkit-details-marker { display: none; }
  .gf-new-repo[open] summary { margin-bottom: 0.5rem; }
  .gf-new-repo form.card { min-width: 320px; }
  .gf-add-repo-panel { margin-bottom: 1.25rem; }
  .gf-add-repo-panel .gf-tabs { margin: 0; padding: 0.3rem 1.1rem 0; }
  .gf-add-repo-panel .gf-tabpane { padding: 1.1rem; }
  .gf-add-repo-panel .gf-tabpane form { max-width: 480px; }

  .gf-input-group { display: flex; align-items: stretch; margin-top: 0.25rem; }
  .gf-input-group input { margin-top: 0; border-radius: 0 6px 6px 0; }
  .gf-input-group .prefix { display: flex; align-items: center; flex-shrink: 0; padding: 0 0.6rem; background: var(--surface-3); border: 1px solid var(--border-strong); border-right: none; border-radius: 6px 0 0 6px; color: var(--text-faint); font-size: 0.9rem; font-family: var(--mono); white-space: nowrap; }

  .gf-page-sub { color: var(--text-dim); font-size: 0.9rem; margin: -0.6rem 0 1.1rem; }
  .gf-search { display: flex; align-items: center; gap: 0.6rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 0.55rem 0.8rem; margin-bottom: 1.1rem; color: var(--text-faint); }
  .gf-search input { border: none; background: none; color: var(--text); font-size: 0.9rem; flex: 1; font-family: inherit; }
  .gf-search input:focus { outline: none; }
  .gf-search input::placeholder { color: var(--text-faint); }

  .gf-topics-row { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; margin-bottom: 1.4rem; }
  .gf-topics-row .label { font-size: 0.76rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.04em; margin-right: 0.3rem; }
  .chip { display: inline-flex; align-items: center; gap: 0.3rem; font-family: var(--mono); font-size: 0.76rem; padding: 0.28rem 0.65rem; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--border); color: var(--text-dim); text-decoration: none; }
  .chip:hover { border-color: var(--border-strong); color: var(--text); }
  .chip.active { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 700; }

  .gf-empty { padding: 2.2rem 1.5rem; text-align: center; }
  .gf-empty .icon { width: 30px; height: 30px; color: var(--text-faint); margin-bottom: 0.7rem; }
  .gf-empty p { margin: 0.2rem 0; }
  .gf-empty .clear { color: var(--accent); font-size: 0.86rem; margin-top: 0.6rem; display: inline-block; }

  .gf-tabs { display: flex; gap: 0.3rem; border-bottom: 1px solid var(--border); margin-bottom: 1.25rem; }
  .gf-tabs button { margin: 0; padding: 0.6rem 0.9rem; font-size: 0.86rem; color: var(--text-dim); border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; background: none; border-radius: 0; cursor: pointer; font-family: inherit; }
  .gf-tabs button:hover { background: none; color: var(--text); }
  .gf-tabs button.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
  .gf-tabpane { display: none; }
  .gf-tabpane.active { display: block; }

  .gf-profile-card { display: flex; align-items: center; gap: 0.9rem; padding: 1.1rem; }
  .gf-profile-card .avatar { width: 46px; height: 46px; border-radius: 50%; background: var(--accent-dim); color: var(--text); display: flex; align-items: center; justify-content: center; font-size: 0.95rem; font-weight: 700; flex-shrink: 0; }
  .gf-profile-card .who { font-size: 1.02rem; font-weight: 700; }
  .gf-profile-card .who .admin-tag { color: var(--accent); font-weight: 600; }
  .gf-profile-card .principal { font-family: var(--mono); font-size: 0.8rem; color: var(--text-faint); margin-top: 0.15rem; }

  .gf-profile-page-head { display: flex; align-items: center; gap: 1rem; margin: 0.5rem 0 1.5rem; }
  .gf-profile-page-head .avatar { width: 64px; height: 64px; border-radius: 50%; background: var(--accent-dim); color: var(--text); display: flex; align-items: center; justify-content: center; font-size: 1.25rem; font-weight: 700; flex-shrink: 0; }
  .gf-profile-page-head h1 { font-size: 1.4rem; margin: 0; }
  .gf-profile-page-head h1 .admin-tag { color: var(--accent); font-weight: 600; }
  .gf-profile-page-head p { margin: 0.2rem 0 0; }

  .gf-list-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.85rem 1.1rem; border-bottom: 1px solid var(--border); }
  .gf-list-row:last-child { border-bottom: none; }
  .gf-list-row:hover { background: var(--surface-2); }
  .gf-list-icon { width: 30px; height: 30px; border-radius: 7px; background: var(--surface-2); display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--text-dim); }
  .gf-list-main { flex: 1; min-width: 0; }
  .gf-list-main .primary { font-family: var(--mono); font-size: 0.86rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .gf-list-main .meta { font-size: 0.76rem; color: var(--text-faint); margin-top: 0.15rem; }
  .gf-list-row button { margin: 0; flex-shrink: 0; }

  .gf-danger-zone { border: 1px solid rgba(242,139,130,0.35); background: rgba(242,139,130,0.05); border-radius: 10px; padding: 1.1rem; margin: 1rem 0; }
  .gf-danger-zone strong { color: var(--danger-fg); font-size: 0.94rem; }
  .gf-danger-zone p { margin: 0.4rem 0 0; }
  .gf-danger-zone button { margin-top: 0.8rem; }

  .gf-admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
  .gf-admin-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.1rem; display: flex; flex-direction: column; gap: 0.5rem; }
  .gf-admin-card .top { display: flex; align-items: center; justify-content: space-between; }
  .gf-admin-card .n { font-size: 1.6rem; font-weight: 700; }
  .gf-admin-card .l { font-size: 0.84rem; color: var(--text-dim); font-weight: 600; }
  .gf-admin-card p { margin: 0; font-size: 0.8rem; color: var(--text-faint); }
  .gf-admin-card a.go { font-size: 0.82rem; color: var(--accent); margin-top: auto; }

  .gf-audit-mini { display: flex; flex-direction: column; }
  .gf-audit-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 1.1rem; border-bottom: 1px solid var(--border); font-size: 0.82rem; }
  .gf-audit-row:last-child { border-bottom: none; }
  .gf-audit-row .t { color: var(--text-faint); font-family: var(--mono); font-size: 0.74rem; width: 62px; flex-shrink: 0; }
  .gf-audit-row .who { color: var(--text-dim); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  @media (max-width: 640px) {
    .gf-page-head { flex-direction: column; align-items: stretch; }
    .gf-repo-row { flex-wrap: wrap; }
  }

  /* ---------- tables ---------- */
  .table-wrap { overflow-x: auto; }
  table { border-collapse: collapse; width: 100%; margin: 1rem 0; display: block; overflow-x: auto; max-width: 100%; }
  th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); font-size: 0.88rem; }
  th { color: var(--text-dim); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.02em; }
  tr:hover td { background: var(--surface-2); }

  /* ---------- forms & buttons ---------- */
  form.inline { display: inline; }
  form.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.1rem; margin: 1rem 0; }
  form.card label { display: block; font-size: 0.85rem; color: var(--text-dim); margin-top: 0.6rem; }
  input, select, textarea { width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; margin-top: 0.25rem; background: var(--canvas); border: 1px solid var(--border-strong); color: var(--text); border-radius: 6px; font-size: 0.9rem; font-family: inherit; }
  textarea { resize: vertical; line-height: 1.5; }
  input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
  button, .gf-btn { margin-top: 0.8rem; padding: 0.45rem 0.9rem; background: var(--surface-2); border: 1px solid var(--border-strong); border-radius: 7px; color: var(--text); font-weight: 600; font-size: 0.86rem; cursor: pointer; display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; }
  button:hover, .gf-btn:hover { background: var(--surface-3); }
  button[type="submit"]:not(.linklike), .gf-btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
  button[type="submit"]:not(.linklike):hover, .gf-btn.primary:hover { filter: brightness(1.08); }
  button.danger { background: var(--danger-bg); border-color: transparent; color: var(--danger-fg); }
  button.danger:hover { background: rgba(242,139,130,0.24); }
  button[type="submit"].primary { background: var(--ok-bg); border-color: transparent; color: var(--ok-fg); }
  button[type="submit"].primary:hover { background: rgba(123,214,168,0.24); }

  /* ---------- misc components ---------- */
  .msg { padding: 0.6rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
  .msg.ok { background: var(--ok-bg); color: var(--ok-fg); }
  .msg.err { background: var(--danger-bg); color: var(--danger-fg); }
  .msg.warn { background: var(--pending-bg); color: var(--pending-fg); }
  .badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-family: var(--mono); text-decoration: none; }
  .badge.pending { background: var(--pending-bg); color: var(--pending-fg); }
  .badge.trusted { background: var(--ok-bg); color: var(--ok-fg); }
  .badge.plain { background: var(--surface-2); border: 1px solid var(--border); color: var(--text-dim); margin-right: 0.3rem; }
  a.badge.plain:hover { background: var(--surface-3); color: var(--text); border-color: var(--border-strong); }
  .badge.danger { background: var(--danger-bg); color: var(--danger-fg); }
  .muted { color: var(--text-dim); font-size: 0.85rem; }

  /* ---------- auth (login) ---------- */
  .gf-auth-center { display: flex; align-items: center; justify-content: center; min-height: 60vh; padding: 2rem 0; }
  .gf-auth-box { width: 100%; max-width: 340px; text-align: center; }
  .gf-auth-brand { display: flex; align-items: center; justify-content: center; gap: 0.5rem; color: var(--accent); font-weight: 700; font-size: 1.15rem; margin-bottom: 0.4rem; }
  .gf-auth-tagline { color: var(--text-faint); font-size: 0.82rem; margin: 0 0 1.6rem; }
  .gf-auth-box form.card { text-align: left; margin: 0; }
  .gf-auth-note { margin: 1.2rem auto 0; font-size: 0.78rem; }
  code, pre { background: var(--surface-2); border-radius: 6px; font-family: var(--mono); font-size: 0.86em; }
  code { padding: 0.15rem 0.4rem; }
  pre { padding: 1rem; overflow-x: auto; border: 1px solid var(--border); line-height: 1.5; }
  pre code { background: none; padding: 0; font-size: 1em; }

  .markdown-body { line-height: 1.65; font-size: 0.96rem; }
  .markdown-body > *:first-child { margin-top: 0; }
  .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { border-bottom: 1px solid var(--border); padding-bottom: 0.4rem; margin: 1.8rem 0 1rem; }
  .markdown-body h1 { font-size: 1.6rem; }
  .markdown-body h2 { font-size: 1.3rem; }
  .markdown-body h3 { font-size: 1.1rem; border-bottom: none; }
  .markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body pre { margin: 0.9rem 0; }
  .markdown-body ul, .markdown-body ol { padding-left: 1.5rem; }
  .markdown-body li + li { margin-top: 0.25rem; }
  .markdown-body a { color: var(--accent); }
  .markdown-body img { max-width: 100%; }
  .markdown-body hr { border: none; border-top: 1px solid var(--border); margin: 1.8rem 0; }
  .markdown-body blockquote { margin-left: 0; padding: 0.2rem 1rem; border-left: 3px solid var(--border-strong); color: var(--text-dim); }
  .markdown-body blockquote p { margin: 0.5rem 0; }
  .markdown-body table { display: block; overflow-x: auto; border-collapse: collapse; width: auto; margin: 1rem 0; }
  .markdown-body th, .markdown-body td { border: 1px solid var(--border); padding: 0.4rem 0.8rem; text-align: left; }
  .markdown-body th { background: var(--surface-2); }
  .markdown-body input[type="checkbox"] { width: auto; margin: 0 0.4em 0 0; }

  footer { max-width: 980px; margin: 2rem auto 1.5rem; padding: 0 1rem; }
  footer a { color: var(--text-faint); font-size: 0.78rem; text-decoration: none; }
  footer a:hover { color: var(--text-dim); }

  @media (max-width: 480px) {
    main { padding: 1rem 0.85rem; }
  }
</style>
</head>
<body>
{{.IconSprite}}
<header class="gf-nav">
  <a href="/" class="gf-nav-brand">{{.BrandMark}}<span>Gitfed</span></a>
  <button class="gf-nav-burger" id="navBurger" aria-label="Toggle menu" aria-expanded="false" aria-controls="navPanel">
    <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3 5h14M3 10h14M3 15h14"/></svg>
  </button>
  <div class="gf-nav-panel" id="navPanel">
    <nav class="gf-nav-links">
      <a href="/"{{if eq .Active "home"}} class="active"{{end}}>{{t .Lang "nav.home"}}</a>
      <a href="/explore"{{if eq .Active "explore"}} class="active"{{end}}>{{t .Lang "nav.explore"}}</a>
      <a href="/security"{{if eq .Active "security"}} class="active"{{end}}>{{t .Lang "nav.security"}}</a>
      {{if .LoggedIn}}<a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>{{t .Lang "nav.dashboard"}}</a>{{end}}
    </nav>
    <form class="gf-nav-search" action="/search" method="get" role="search">
      <div class="gf-nav-search-inner">
        <svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="9" cy="9" r="6.5"/><line x1="14" y1="14" x2="18" y2="18"/></svg>
        <input type="search" name="q" id="navSearch" placeholder="{{t .Lang "nav.search_placeholder"}}" value="{{.SearchQuery}}" autocomplete="off">
        <kbd>⌘K</kbd>
      </div>
    </form>
    <div class="gf-nav-right">
      <a href="/theme/{{.ThemeNext}}?next={{.NextPath}}" class="gf-bell" title="{{t .Lang "theme.toggle"}}" aria-label="{{t .Lang "theme.toggle"}}">
        {{icon .ThemeIcon}}
      </a>
      <div class="gf-lang">
        <a href="/lang/en?next={{.NextPath}}"{{if eq .Lang "en"}} class="active"{{end}}>EN</a>
        <a href="/lang/fr?next={{.NextPath}}"{{if eq .Lang "fr"}} class="active"{{end}}>FR</a>
      </div>
      {{if .LoggedIn}}
        {{if .PendingTrust}}
        <a href="/admin/trust" class="gf-bell" title="{{t .Lang "admin.pending_trust_tooltip"}}" aria-label="{{t .Lang "admin.pending_trust_tooltip"}}">
          {{icon "shield"}}<span class="gf-bell-badge">{{.PendingTrust}}</span>
        </a>
        {{end}}
        <a href="/notifications" class="gf-bell" title="{{t .Lang "nav.notifications"}}" aria-label="{{t .Lang "nav.notifications"}}">
          {{icon "bell"}}{{if .PendingNotifs}}<span class="gf-bell-badge">{{.PendingNotifs}}</span>{{end}}
        </a>
        <div class="gf-profile">
          <button class="gf-profile-trigger" id="profileTrigger" aria-haspopup="true" aria-expanded="false" aria-controls="profileMenu">
            <span class="gf-avatar" title="{{.Username}}">{{.Initials}}</span>
            <span>{{.Username}}</span>
            <span class="chev">▾</span>
          </button>
          <div class="gf-profile-menu" id="profileMenu">
            <div class="who">{{t .Lang "nav.signed_in_as"}} <strong>{{.Username}}</strong></div>
            <a href="/settings">{{icon "settings"}} {{t .Lang "nav.settings"}}</a>
            {{if .IsAdmin}}<a href="/admin">{{icon "shield"}} {{t .Lang "nav.admin"}}</a>{{end}}
            <div class="divider"></div>
            <form method="post" action="/logout"><button class="danger" type="submit">{{icon "logout"}} {{t .Lang "nav.logout"}}</button></form>
          </div>
        </div>
      {{else}}
        <a href="/login" class="gf-btn primary">{{t .Lang "nav.login"}}</a>
      {{end}}
    </div>
  </div>
</header>
<main>
{{.Body}}
</main>
<footer>
  <a href="/changelog">{{t .Lang "footer.changelog" .Version}}</a>
</footer>
<script>` + shellScriptJS + `</script>
</body>
</html>`

// shellScriptJS is the shell's inline script, kept as its own constant so its
// SHA-256 can be pinned in the Content-Security-Policy (see securityHeaders):
// with a strict script-src there is no 'unsafe-inline', so the browser only
// runs this block if its hash matches. Any edit here changes that hash, which
// is recomputed at init — the two never drift.
const shellScriptJS = `
(function () {
  var burger = document.getElementById('navBurger');
  var panel = document.getElementById('navPanel');
  if (burger && panel) {
    burger.addEventListener('click', function () {
      var open = panel.classList.toggle('open');
      burger.setAttribute('aria-expanded', open ? 'true' : 'false');
    });
  }
  document.querySelectorAll('[data-toggle]').forEach(function (btn) {
    var panel = document.getElementById(btn.getAttribute('data-toggle'));
    if (!panel) return;
    btn.addEventListener('click', function () {
      var open = panel.hidden;
      panel.hidden = !open;
      btn.setAttribute('aria-expanded', open ? 'true' : 'false');
    });
  });
  var profileTrigger = document.getElementById('profileTrigger');
  var profileMenu = document.getElementById('profileMenu');
  if (profileTrigger && profileMenu) {
    profileTrigger.addEventListener('click', function (e) {
      e.stopPropagation();
      var open = profileMenu.classList.toggle('open');
      profileTrigger.setAttribute('aria-expanded', open ? 'true' : 'false');
    });
    document.addEventListener('click', function () {
      profileMenu.classList.remove('open');
      profileTrigger.setAttribute('aria-expanded', 'false');
    });
  }
  document.addEventListener('keydown', function (e) {
    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
      var s = document.getElementById('navSearch');
      if (s) { e.preventDefault(); s.focus(); }
    }
  });
  document.addEventListener('click', function (e) {
    var btn = e.target.closest('[data-copy]');
    if (!btn || !navigator.clipboard) return;
    navigator.clipboard.writeText(btn.getAttribute('data-copy')).then(function () {
      var orig = btn.innerHTML;
      btn.innerHTML = '<svg class="icon" aria-hidden="true"><use href="#ic-check"/></svg>';
      setTimeout(function () { btn.innerHTML = orig; }, 1200);
    });
  });
  document.addEventListener('click', function (e) {
    var tab = e.target.closest('[data-tab]');
    if (!tab) return;
    var group = tab.closest('.gf-tabs');
    if (!group) return;
    group.querySelectorAll('button').forEach(function (b) { b.classList.remove('active'); });
    tab.classList.add('active');
    var panes = group.parentElement.querySelectorAll('.gf-tabpane');
    panes.forEach(function (p) { p.classList.toggle('active', p.id === tab.getAttribute('data-tab')); });
  });
  // Delegated confirm for destructive forms, replacing inline onsubmit
  // handlers (which a strict CSP script-src would block).
  document.addEventListener('submit', function (e) {
    var f = e.target.closest('form[data-confirm]');
    if (f && !window.confirm(f.getAttribute('data-confirm'))) {
      e.preventDefault();
    }
  });
  // Derives a repo name from the pasted source URL's last path segment, on
  // the one-shot import form — only while the name field is still empty or
  // still holds a previous auto-fill, so it never clobbers a name someone
  // typed themselves.
  document.addEventListener('input', function (e) {
    if (e.target.id !== 'importUrl') return;
    var nameInput = document.getElementById('importName');
    if (!nameInput || (nameInput.value !== '' && nameInput.value !== nameInput.dataset.autofilled)) return;
    var m = e.target.value.trim().match(/\/([^\/]+?)(\.git)?\/?$/);
    var derived = m ? m[1] : '';
    nameInput.value = derived;
    nameInput.dataset.autofilled = derived;
  });
})();
`

// shellSrc is the full page shell: the static head/body markup (with template
// actions) followed by the inline script, concatenated so the bytes between
// <script>…</script> are exactly shellScriptJS and match the CSP hash.
const shellSrc = shellHeadSrc

var shellTpl = newTpl("shell", shellSrc)

func (s *server) render(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML) {
	sess, loggedIn := s.currentSession(r)
	var pendingNotifs, pendingTrust int
	if loggedIn {
		pendingNotifs, _ = s.ops.CountPendingNotifications(sess.Principal)
		if sess.IsAdmin {
			pendingTrust, _ = s.ops.CountPendingTrust()
		}
	}
	theme := s.theme(r)
	themeIcon, themeNext := themeToggleNext(theme)
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_ = shellTpl.Execute(w, struct {
		Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, Theme, ThemeIcon, ThemeNext, NextPath string
		LoggedIn, IsAdmin                                                                                            bool
		PendingNotifs, PendingTrust                                                                                  int
		Body, BrandMark, IconSprite                                                                                  template.HTML
	}{
		title, s.domain, active, sess.Username, version.Version, initials(sess.Username), r.URL.Query().Get("q"),
		string(s.lang(r)), theme, themeIcon, themeNext, r.URL.RequestURI(),
		loggedIn, sess.IsAdmin, pendingNotifs, pendingTrust, body, template.HTML(brandMark), template.HTML(iconSprite),
	})
}

// initials turns a username into a one-or-two-letter avatar label:
// "bastien-mrq" -> "BM", "alice" -> "AL".
func initials(username string) string {
	var parts []string
	for _, p := range strings.FieldsFunc(username, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) {
		if p != "" {
			parts = append(parts, p)
		}
	}
	switch {
	case len(parts) >= 2:
		return strings.ToUpper(parts[0][:1] + parts[1][:1])
	case len(parts) == 1 && len(parts[0]) >= 2:
		return strings.ToUpper(parts[0][:2])
	case len(parts) == 1:
		return strings.ToUpper(parts[0])
	default:
		return "?"
	}
}

// flash renders the ?msg=&err= query params (set by handlers that redirect
// after a POST) as a status banner.
func flash(r *http.Request) template.HTML {
	msg := r.URL.Query().Get("msg")
	if msg == "" {
		return ""
	}
	class := "ok"
	switch {
	case r.URL.Query().Get("err") == "1":
		class = "err"
	case r.URL.Query().Get("warn") == "1":
		// A partial success — the action itself worked, but something
		// still needs attention (e.g. a federated grant left the
		// collaborator's domain pending trust approval). Not a failure,
		// so not .err — but not a plain .ok either.
		class = "warn"
	}
	return template.HTML(`<div class="msg ` + class + `">` + template.HTMLEscapeString(msg) + `</div>`)
}

// serverError logs the real error server-side and returns a generic message
// to the client, so internal details (store paths, wrapped errors) never leak
// into an HTTP response body.
func (s *server) serverError(w http.ResponseWriter, r *http.Request, err error) {
	log.Printf("gitfed-web: %s %s: %v", r.Method, r.URL.Path, err)
	http.Error(w, i18n.T(s.lang(r), "common.server_error"), http.StatusInternalServerError)
}

func redirectWithMsg(w http.ResponseWriter, r *http.Request, path, msg string, isErr bool) {
	sep := "?"
	if strings.Contains(path, "?") {
		sep = "&"
	}
	q := sep + "msg=" + template.URLQueryEscaper(msg)
	if isErr {
		q += "&err=1"
	}
	http.Redirect(w, r, path+q, http.StatusSeeOther)
}

// redirectWithWarnMsg is redirectWithMsg's third state — see flash()'s
// "warn" case.
func redirectWithWarnMsg(w http.ResponseWriter, r *http.Request, path, msg string) {
	sep := "?"
	if strings.Contains(path, "?") {
		sep = "&"
	}
	http.Redirect(w, r, path+sep+"msg="+template.URLQueryEscaper(msg)+"&warn=1", http.StatusSeeOther)
}