Files
projax/web/templates/layout.tmpl
mAi 9d0dd74695 feat(layout): desktop sidebar replaces top-nav
Phase 5g slice A. m wants projax aligned with mBrian's nav layout: fixed-
left sidebar on desktop, bottom-nav on mobile (slice B). This slice drops
the top-nav <header> and ships the desktop sidebar; the ≤767px viewport
temporarily renders nav-less until slice B lands the bottom-nav.

web/templates/layout.tmpl:
- Delete the old <header><nav>...</nav></header>. Replace with
  <aside class="projax-sidebar"> carrying:
    * .sidebar-top: brand (▦ + "projax")
    * .sidebar-nav: 6 items (Tree → Dashboard → Calendar → Timeline →
      Graph → Admin) with inline SVG icons. Active class set server-side
      via `{{if eq $path "/dashboard"}}active{{end}}`.
    * .sidebar-bottom: theme toggle + sign-out form + collapse toggle.
- Content wrapped in <main class="projax-main">.
- New pre-paint <script> in <head> reads
  localStorage["projax.sidebar.collapsed"] and sets
  data-sidebar-collapsed="true" on <html> BEFORE first paint so the
  main-content margin doesn't flash 220px→56px on every navigation.
- Existing theme-toggle JS unchanged (the button is just relocated). New
  body-end <script> wires the #sidebar-collapse button: toggle the
  attribute, persist to localStorage, sync aria-expanded + title.
- DO NOT port mBrian's resize handle — that's the $effect-feedback bug
  mBrian debugged at length. Static 220/56px is fine for v1.

web/static/style.css:
- Strip the pre-5g `header { ... }`, `header nav { ... }`,
  `header .logout-form { ... }`, `header .brand { ... }`,
  `header .theme-toggle { ... }` rules and the matching @media
  overrides (320×, 480× targeted `header`).
- New `main.projax-main` rule: `margin-left: var(--projax-sidebar-width,
  220px)` on desktop, transitions on collapse. The
  `html[data-sidebar-collapsed="true"]` selector flips the var to 56px.
  Mobile (≤767px) zeros the margin.
- New `.projax-sidebar` block: fixed-left, z-index 50, .nav-item /
  .nav-icon / .nav-label rules, .active border-left accent (matches
  mBrian's `border-left: 2px solid #8cf` pattern but uses var(--accent)
  so it round-trips dark/light theme).
- @media (max-width: 767px) hides the sidebar so the phone isn't stuck
  with a 220px-wide hole until slice B.

web/server.go:
- render() injects `Path: r.URL.Path` into the template data map (unless
  caller pre-set it for tests) so the layout can mark the active nav
  item without any per-handler boilerplate.

Tests (web/layout_test.go):
- TestLayoutSidebarOnDesktop: aside present, all six href + label pairs
  rendered.
- TestLayoutActiveClass: /dashboard render has the Dashboard item with
  .active and Tree without.
- TestLayoutCollapseScript: pre-paint localStorage restore + the
  collapse-toggle handler both present.
- TestLayoutNoTopHeader: belt-and-braces — the pre-5g <header> and
  .logout-btn classes are gone.

All existing tests stay green (TestLayoutHasAdminNavLink,
TestLayoutHasManifestAndAppleTouchIcon, TestLayoutHasViewportMeta,
TestCalendar*, TestTreeRenders, etc.). No test source edits required —
existing assertions look at page CONTENT, not chrome.
2026-05-25 16:36:10 +02:00

169 lines
9.4 KiB
Cheetah

{{define "layout"}}<!doctype html>
<html lang="en" data-theme="{{.Theme}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="{{.ThemeColor}}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="projax">
<title>{{.Title}} — projax</title>
<link rel="manifest" href="/static/manifest.webmanifest">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png">
<link rel="stylesheet" href="/static/style.css">
<script>
// Phase 5g — restore sidebar collapsed state BEFORE first paint so the
// main-content margin doesn't flash from 220px→56px on every navigation.
// Mirrors layout.tmpl's existing theme-cookie approach (server-rendered
// first paint, no FOUC) but localStorage-backed because collapse state
// doesn't need to round-trip the server.
(function() {
try {
if (localStorage.getItem('projax.sidebar.collapsed') === 'true') {
document.documentElement.setAttribute('data-sidebar-collapsed', 'true');
}
} catch (e) { /* private mode / disabled storage — fall through */ }
})();
</script>
<script>
// Phase 3j — register the service worker post-load so the install
// affordance fires and shell assets warm into cache. Failures are silent
// (older browsers, http context, etc.) — projax must work without SW.
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/static/sw.js').catch(function(){});
});
}
</script>
</head>
<body>
{{$path := .Path}}
<aside class="projax-sidebar" aria-label="Primary navigation">
<div class="sidebar-top">
<a href="/" class="brand" title="projax">
<span class="brand-icon" aria-hidden="true">▦</span>
<strong class="brand-label">projax</strong>
</a>
</div>
<nav class="sidebar-nav">
<a href="/" class="nav-item{{if eq $path "/"}} active{{end}}" title="Tree">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
<span class="nav-label">Tree</span>
</a>
<a href="/dashboard" class="nav-item{{if eq $path "/dashboard"}} active{{end}}" title="Dashboard">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
</svg>
<span class="nav-label">Dashboard</span>
</a>
<a href="/calendar" class="nav-item{{if eq $path "/calendar"}} active{{end}}" title="Calendar">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span class="nav-label">Calendar</span>
</a>
<a href="/timeline" class="nav-item{{if eq $path "/timeline"}} active{{end}}" title="Timeline">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span class="nav-label">Timeline</span>
</a>
<a href="/graph" class="nav-item{{if eq $path "/graph"}} active{{end}}" title="Graph">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
<span class="nav-label">Graph</span>
</a>
<a href="/admin" class="nav-item{{if eq $path "/admin"}} active{{end}}" title="Admin">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="nav-label">Admin</span>
</a>
</nav>
<div class="sidebar-bottom">
<button type="button" id="theme-toggle" class="nav-item theme-toggle"
title="Toggle dark / light" aria-label="Toggle theme" data-theme="{{.Theme}}">
<span class="nav-icon theme-icon" aria-hidden="true">{{if eq .Theme "light"}}☾{{else}}☀{{end}}</span>
<span class="nav-label">Theme</span>
</button>
<form method="post" action="/logout" class="logout-form">
<button type="submit" class="nav-item logout-item" title="Sign out">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span class="nav-label">Sign out</span>
</button>
</form>
<button type="button" id="sidebar-collapse" class="nav-item collapse-btn"
title="Collapse sidebar" aria-label="Collapse sidebar" aria-expanded="true">
<svg class="nav-icon collapse-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"/>
</svg>
<span class="nav-label">Collapse</span>
</button>
</div>
</aside>
<main class="projax-main">
{{template "content" .}}
</main>
<script>
// Phase 4b — theme toggle. Writes the projax_theme cookie (1-year Max-Age,
// Path=/, SameSite=Lax) and flips data-theme + theme-color in place so the
// user sees the swap without a reload. Server already injected the initial
// value from the cookie, so first paint never flashes the wrong theme.
(function() {
var btn = document.getElementById('theme-toggle');
if (!btn) return;
var icon = btn.querySelector('.theme-icon');
var root = document.documentElement;
var meta = document.querySelector('meta[name="theme-color"]');
var themeColors = { dark: '#161616', light: '#f0efe8' };
btn.addEventListener('click', function() {
var current = root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
var next = current === 'light' ? 'dark' : 'light';
root.setAttribute('data-theme', next);
btn.setAttribute('data-theme', next);
if (icon) icon.textContent = next === 'light' ? '☾' : '☀';
if (meta) meta.setAttribute('content', themeColors[next]);
var oneYear = 60 * 60 * 24 * 365;
document.cookie = 'projax_theme=' + next + '; Max-Age=' + oneYear + '; Path=/; SameSite=Lax';
});
})();
</script>
<script>
// Phase 5g — sidebar collapse toggle. State persists in localStorage; the
// pre-paint script in <head> already restored data-sidebar-collapsed on
// <html>, so this handler only writes the new state. Aria-expanded
// tracks the open/closed state for screen readers.
(function() {
var btn = document.getElementById('sidebar-collapse');
if (!btn) return;
var root = document.documentElement;
function sync() {
var collapsed = root.getAttribute('data-sidebar-collapsed') === 'true';
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
btn.setAttribute('title', collapsed ? 'Expand sidebar' : 'Collapse sidebar');
btn.setAttribute('aria-label', collapsed ? 'Expand sidebar' : 'Collapse sidebar');
}
sync();
btn.addEventListener('click', function() {
var collapsed = root.getAttribute('data-sidebar-collapsed') === 'true';
var next = !collapsed;
if (next) root.setAttribute('data-sidebar-collapsed', 'true');
else root.removeAttribute('data-sidebar-collapsed');
try { localStorage.setItem('projax.sidebar.collapsed', next ? 'true' : 'false'); } catch (e) {}
sync();
});
})();
</script>
</body>
</html>{{end}}