diff --git a/app.js b/app.js
new file mode 100644
index 0000000..e77fb1f
--- /dev/null
+++ b/app.js
@@ -0,0 +1,639 @@
+/* Hermes Wiki β Touch-First JS for Phase 2
+ *
+ * Features:
+ * - Tree render with collapsible folders
+ * - Search with live results dropdown (title + tags)
+ * - 3D Graph in modal (Three.js, NO auto-rotation, click-to-navigate)
+ * - Mobile bottom-nav (Files, Search, Graph)
+ * - Hamburger tree + FAB graph on mobile
+ * - Backdrop click closes overlays
+ * - Backlinks rendering
+ * - Hash-based navigation (forward/back via pushState)
+ */
+(function() {
+ 'use strict';
+
+ const data = window.HERMES_DATA;
+ if (!data) {
+ console.error('[Hermes] HERMES_DATA missing');
+ return;
+ }
+
+ const currentSlug = window.location.pathname
+ .replace(/^\//, '')
+ .replace(/\.html$/, '');
+
+ // ============================================================
+ // Tree render
+ // ============================================================
+
+ function renderTree(node, container, basePath) {
+ const wrap = document.createElement('div');
+ wrap.className = 'tree-root';
+
+ if (node.folders && node.folders.length > 0) {
+ for (const folder of node.folders) {
+ const fEl = document.createElement('div');
+ fEl.className = 'tree-folder';
+ if (folder.name === 'concepts' || folder.name === 'entities') {
+ fEl.classList.add('open'); // auto-open most-used folders
+ }
+ const header = document.createElement('div');
+ header.className = 'tree-folder-header';
+ header.innerHTML = `βΆ${escapeHtml(folder.name)}`;
+ header.onclick = () => fEl.classList.toggle('open');
+ fEl.appendChild(header);
+ const children = document.createElement('div');
+ children.className = 'tree-children';
+ fEl.appendChild(children);
+ renderTree(folder, children, basePath + '/' + folder.name);
+ wrap.appendChild(fEl);
+ }
+ }
+
+ if (node.files && node.files.length > 0) {
+ for (const file of node.files) {
+ const a = document.createElement('a');
+ a.href = '/' + file.slug + '.html';
+ a.className = 'tree-file';
+ if (file.slug === currentSlug) a.classList.add('current');
+ a.innerHTML = `π${escapeHtml(file.title)}`;
+ wrap.appendChild(a);
+ }
+ }
+
+ container.appendChild(wrap);
+ }
+
+ // ============================================================
+ // Search
+ // ============================================================
+
+ function setupSearch() {
+ const input = document.getElementById('search');
+ if (!input) return;
+
+ // Wrap with relative parent for absolute dropdown
+ const wrapper = document.createElement('div');
+ wrapper.className = 'search-wrap';
+ input.parentNode.insertBefore(wrapper, input);
+ wrapper.appendChild(input);
+ input.style.paddingLeft = '36px';
+
+ const dropdown = document.createElement('div');
+ dropdown.className = 'search-results';
+ wrapper.appendChild(dropdown);
+
+ let activeIdx = -1;
+
+ input.addEventListener('input', (e) => {
+ const q = e.target.value.toLowerCase().trim();
+ if (!q) {
+ dropdown.classList.remove('open');
+ return;
+ }
+ const results = data.pages.filter(p =>
+ (p.title || '').toLowerCase().includes(q) ||
+ (p.slug || '').toLowerCase().includes(q) ||
+ (p.tags || []).some(t => t.toLowerCase().includes(q))
+ ).slice(0, 20);
+
+ dropdown.innerHTML = '';
+ if (results.length === 0) {
+ dropdown.innerHTML = '
Keine Treffer
';
+ } else {
+ for (let i = 0; i < results.length; i++) {
+ const r = results[i];
+ const a = document.createElement('a');
+ a.href = '/' + r.slug + '.html';
+ a.className = 'search-result' + (i === activeIdx ? ' active' : '');
+ const tagsHtml = (r.tags || []).slice(0, 3).map(t => `${escapeHtml(t)}`).join('');
+ a.innerHTML = `${escapeHtml(r.title)}${tagsHtml}${escapeHtml(r.slug)}`;
+ a.onmouseenter = () => {
+ activeIdx = i;
+ updateActive();
+ };
+ dropdown.appendChild(a);
+ }
+ }
+ dropdown.classList.add('open');
+
+ function updateActive() {
+ [...dropdown.querySelectorAll('.search-result')].forEach((el, idx) => {
+ el.classList.toggle('active', idx === activeIdx);
+ });
+ }
+ });
+
+ input.addEventListener('keydown', (e) => {
+ const items = [...dropdown.querySelectorAll('.search-result')];
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ activeIdx = Math.min(activeIdx + 1, items.length - 1);
+ [...dropdown.querySelectorAll('.search-result')].forEach((el, idx) =>
+ el.classList.toggle('active', idx === activeIdx)
+ );
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ activeIdx = Math.max(activeIdx - 1, 0);
+ [...dropdown.querySelectorAll('.search-result')].forEach((el, idx) =>
+ el.classList.toggle('active', idx === activeIdx)
+ );
+ } else if (e.key === 'Enter' && activeIdx >= 0 && items[activeIdx]) {
+ e.preventDefault();
+ items[activeIdx].click();
+ } else if (e.key === 'Escape') {
+ input.value = '';
+ dropdown.classList.remove('open');
+ }
+ });
+
+ // Close dropdown on outside click
+ document.addEventListener('click', (e) => {
+ if (!wrapper.contains(e.target)) {
+ dropdown.classList.remove('open');
+ }
+ });
+ }
+
+ // ============================================================
+ // Graph (Three.js) β modal, NO auto-rotation, click-to-navigate
+ // ============================================================
+
+ let graphState = null;
+
+ function buildGraph(canvas, container) {
+ if (!window.THREE) {
+ console.error('[Hermes] THREE.js not loaded');
+ return;
+ }
+ const ctx = {
+ scene: new THREE.Scene(),
+ camera: null,
+ renderer: new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }),
+ nodes: new Map(),
+ edges: [],
+ currentSlug: currentSlug,
+ isDragging: false,
+ dragStart: { x: 0, y: 0 },
+ cameraPos: { x: 0, y: 0, z: 1200 },
+ cameraTarget: { x: 0, y: 0, z: 0 },
+ needsRender: true,
+ hoveredNode: null,
+ };
+
+ const w = canvas.clientWidth || canvas.width || 600;
+ const h = canvas.clientHeight || canvas.height || 400;
+
+ ctx.renderer.setSize(w, h, false);
+ ctx.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ ctx.renderer.setClearColor(0x0e0e1a, 1);
+
+ ctx.camera = new THREE.PerspectiveCamera(60, w / h, 1, 10000);
+ ctx.camera.position.set(0, 0, 1200);
+ ctx.camera.lookAt(0, 0, 0);
+
+ // Build nodes via deterministic layout (Fruchterman-Reingold style)
+ const graph = data.graph;
+ const slugs = graph.nodes.map(n => n.id);
+ const positions = computeLayout(slugs, graph.edges, 600);
+
+ // Group nodes by folder for visual variety
+ const folderColors = {};
+ let colorIdx = 0;
+ const palette = [0x89b4fa, 0xa6e3a1, 0xf9e2af, 0xf5c2e7, 0x94e2d5, 0xfab387, 0xcba6f7];
+ for (const slug of slugs) {
+ const folder = slug.split('/')[0] || 'root';
+ if (!(folder in folderColors)) {
+ folderColors[folder] = palette[colorIdx++ % palette.length];
+ }
+ }
+
+ // Current page edges for highlight
+ const currentEdges = new Set();
+ const currentReverse = new Set();
+ if (graph.edges) {
+ for (const e of graph.edges) {
+ if (e.source === currentSlug) {
+ currentEdges.add(e.target);
+ currentReverse.add(e.source);
+ }
+ if (e.target === currentSlug) {
+ currentEdges.add(e.source);
+ }
+ }
+ }
+
+ // Build meshes
+ const sphereGeo = new THREE.SphereGeometry(5, 12, 12);
+ for (const node of graph.nodes) {
+ const isCurrent = node.id === currentSlug;
+ const isConnected = currentEdges.has(node.id);
+ const folder = node.id.split('/')[0] || 'root';
+ const baseColor = folderColors[folder];
+
+ let color = baseColor;
+ let opacity = 0.5;
+ let size = 1;
+ if (isCurrent) {
+ color = 0xFFD700;
+ opacity = 1;
+ size = 2.2;
+ } else if (isConnected) {
+ color = 0xFFD700;
+ opacity = 0.95;
+ size = 1.5;
+ } else if (currentSlug) {
+ opacity = 0.25;
+ } else {
+ opacity = 0.6;
+ }
+
+ const mat = new THREE.MeshBasicMaterial({
+ color,
+ transparent: true,
+ opacity,
+ depthWrite: isCurrent || isConnected,
+ });
+ const mesh = new THREE.Mesh(sphereGeo, mat);
+ mesh.position.set(positions[node.id].x, positions[node.id].y, positions[node.id].z);
+ mesh.scale.setScalar(size);
+ mesh.userData = { nodeId: node.id, baseScale: size, baseOpacity: opacity, isCurrent, isConnected };
+ ctx.scene.add(mesh);
+ ctx.nodes.set(node.id, mesh);
+ }
+
+ // Build edges
+ const edgeMat = new THREE.LineBasicMaterial({
+ color: 0x6c7086,
+ transparent: true,
+ opacity: 0.35,
+ });
+ const currentEdgeMat = new THREE.LineBasicMaterial({
+ color: 0xFFD700,
+ transparent: true,
+ opacity: 0.85,
+ });
+ for (const e of graph.edges) {
+ const src = ctx.nodes.get(e.source);
+ const tgt = ctx.nodes.get(e.target);
+ if (!src || !tgt) continue;
+ const lineGeo = new THREE.BufferGeometry().setFromPoints([src.position, tgt.position]);
+ const isCurrentEdge = (e.source === currentSlug || e.target === currentSlug);
+ const line = new THREE.Line(lineGeo, isCurrentEdge ? currentEdgeMat : edgeMat);
+ ctx.scene.add(line);
+ }
+
+ // Render only on demand (NO animation loop!)
+ function render() {
+ ctx.renderer.render(ctx.scene, ctx.camera);
+ }
+
+ // Manual rotation: drag to rotate
+ let isPointerDown = false;
+ let lastX = 0, lastY = 0;
+ function onPointerDown(e) {
+ isPointerDown = true;
+ ctx.isDragging = false;
+ const p = pointerXY(e, canvas);
+ lastX = p.x; lastY = p.y;
+ }
+ function onPointerMove(e) {
+ if (!isPointerDown) return;
+ const p = pointerXY(e, canvas);
+ const dx = p.x - lastX;
+ const dy = p.y - lastY;
+ if (Math.abs(dx) + Math.abs(dy) > 4) ctx.isDragging = true;
+ // Rotate camera around origin
+ const rotSpeed = 0.005;
+ const offset = new THREE.Vector3().subVectors(ctx.camera.position, ctx.cameraTarget);
+ const spherical = new THREE.Spherical().setFromVector3(offset);
+ spherical.theta -= dx * rotSpeed;
+ spherical.phi -= dy * rotSpeed;
+ spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi));
+ offset.setFromSpherical(spherical);
+ ctx.camera.position.copy(ctx.cameraTarget).add(offset);
+ ctx.camera.lookAt(ctx.cameraTarget);
+ lastX = p.x; lastY = p.y;
+ render();
+ }
+ function onPointerUp(e) {
+ if (!isPointerDown) return;
+ isPointerDown = false;
+ if (!ctx.isDragging) {
+ // Click β raycast for node
+ const p = pointerXY(e, canvas);
+ const raycaster = new THREE.Raycaster();
+ const mouse = new THREE.Vector2(p.x, p.y);
+ raycaster.setFromCamera(mouse, ctx.camera);
+ const meshes = Array.from(ctx.nodes.values());
+ const intersects = raycaster.intersectObjects(meshes);
+ if (intersects.length > 0) {
+ const nodeId = intersects[0].object.userData.nodeId;
+ if (nodeId) {
+ // Navigate
+ const slug = nodeId;
+ const modal = document.querySelector('.graph-modal');
+ if (modal) modal.classList.remove('open');
+ window.location.href = '/' + slug + '.html';
+ }
+ }
+ }
+ }
+ function pointerXY(e, canvas) {
+ const rect = canvas.getBoundingClientRect();
+ const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
+ const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
+ return { x, y };
+ }
+
+ canvas.addEventListener('pointerdown', onPointerDown);
+ canvas.addEventListener('pointermove', onPointerMove);
+ canvas.addEventListener('pointerup', onPointerUp);
+ canvas.addEventListener('pointercancel', onPointerUp);
+
+ // Zoom with wheel
+ canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ const dir = e.deltaY > 0 ? 1.1 : 0.9;
+ ctx.camera.position.multiplyScalar(dir);
+ ctx.camera.lookAt(ctx.cameraTarget);
+ render();
+ }, { passive: false });
+
+ // Resize handling
+ function onResize() {
+ const w2 = canvas.clientWidth;
+ const h2 = canvas.clientHeight;
+ if (w2 === 0 || h2 === 0) return;
+ ctx.camera.aspect = w2 / h2;
+ ctx.camera.updateProjectionMatrix();
+ ctx.renderer.setSize(w2, h2, false);
+ render();
+ }
+ window.addEventListener('resize', onResize);
+
+ // First render
+ render();
+
+ ctx.render = render;
+ ctx.cleanup = () => {
+ canvas.removeEventListener('pointerdown', onPointerDown);
+ canvas.removeEventListener('pointermove', onPointerMove);
+ canvas.removeEventListener('pointerup', onPointerUp);
+ canvas.removeEventListener('pointercancel', onPointerUp);
+ window.removeEventListener('resize', onResize);
+ };
+ return ctx;
+ }
+
+ function computeLayout(nodeIds, edges, radius) {
+ // Simple force-directed-ish: angle-distributed with edge-based clustering
+ const positions = {};
+ const N = nodeIds.length;
+ if (N === 0) return positions;
+
+ // Initialize: distribute on sphere
+ for (let i = 0; i < N; i++) {
+ const phi = Math.acos(-1 + (2 * i) / N);
+ const theta = Math.sqrt(N * Math.PI) * phi;
+ positions[nodeIds[i]] = {
+ x: radius * Math.cos(theta) * Math.sin(phi),
+ y: radius * Math.sin(theta) * Math.sin(phi),
+ z: radius * Math.cos(phi),
+ };
+ }
+
+ // 1-2 iterations of relaxation
+ for (let iter = 0; iter < 2; iter++) {
+ // Repulsion
+ for (let i = 0; i < N; i++) {
+ const a = nodeIds[i];
+ let fx = 0, fy = 0, fz = 0;
+ for (let j = 0; j < N; j++) {
+ if (i === j) continue;
+ const b = nodeIds[j];
+ const dx = positions[a].x - positions[b].x;
+ const dy = positions[a].y - positions[b].y;
+ const dz = positions[a].z - positions[b].z;
+ const d2 = dx*dx + dy*dy + dz*dz + 0.01;
+ const f = 50 / d2;
+ fx += dx * f;
+ fy += dy * f;
+ fz += dz * f;
+ }
+ positions[a].x += fx * 0.01;
+ positions[a].y += fy * 0.01;
+ positions[a].z += fz * 0.01;
+ }
+ }
+ return positions;
+ }
+
+ // ============================================================
+ // Backlinks render
+ // ============================================================
+
+ function renderBacklinks() {
+ const container = document.getElementById('backlinks');
+ if (!container) return;
+ const links = (data.backlinks && data.backlinks[currentSlug]) || [];
+ if (links.length === 0) {
+ container.innerHTML = '';
+ return;
+ }
+ const html = [
+ 'Backlinks (' + links.length + ')
',
+ '' + links.map(slug => {
+ const page = data.pages.find(p => p.slug === slug);
+ const title = page ? page.title : slug;
+ return `- ${escapeHtml(title)}
`;
+ }).join('') + '
'
+ ].join('');
+ container.innerHTML = html;
+ }
+
+ // ============================================================
+ // TOC (extract from rendered headings)
+ // ============================================================
+
+ function renderTOC() {
+ const container = document.getElementById('toc');
+ if (!container) return;
+ const headings = [...document.querySelectorAll('.note-body h2, .note-body h3')];
+ if (headings.length === 0) {
+ container.innerHTML = '';
+ return;
+ }
+ const items = headings.map(h => {
+ const level = h.tagName === 'H2' ? 2 : 3;
+ const text = h.textContent.replace(/\s*ΒΆ\s*$/, '').trim();
+ const id = h.id || text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+ h.id = id;
+ return `${escapeHtml(text)}`;
+ }).join('');
+ container.innerHTML = `Inhalt
`;
+ }
+
+ // ============================================================
+ // Mobile bottom-nav handlers
+ // ============================================================
+
+ function setupBottomNav() {
+ document.querySelectorAll('.app-bottom-nav button').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const action = btn.dataset.action;
+ if (action === 'toggle-tree') {
+ toggleTree();
+ } else if (action === 'toggle-graph') {
+ openGraphModal();
+ } else if (action === 'focus-search') {
+ const s = document.getElementById('search');
+ if (s) {
+ s.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ s.focus();
+ }
+ }
+ });
+ });
+ }
+
+ function toggleTree() {
+ const tree = document.querySelector('.app-tree');
+ const backdrop = document.querySelector('.backdrop');
+ if (!tree) return;
+ const isOpen = tree.classList.contains('open');
+ tree.classList.toggle('open');
+ if (backdrop) backdrop.classList.toggle('open', !isOpen);
+ }
+
+ function openGraphModal() {
+ const modal = document.querySelector('.graph-modal');
+ if (!modal) return;
+ modal.classList.add('open');
+ document.body.style.overflow = 'hidden';
+
+ // Build graph in modal canvas (once)
+ const canvas = modal.querySelector('canvas');
+ if (canvas && !canvas.dataset.built) {
+ canvas.dataset.built = '1';
+ // Wait for layout
+ requestAnimationFrame(() => {
+ graphState = buildGraph(canvas, modal);
+ });
+ } else if (canvas && graphState) {
+ graphState.render();
+ }
+ }
+
+ function closeGraphModal() {
+ const modal = document.querySelector('.graph-modal');
+ if (!modal) return;
+ modal.classList.remove('open');
+ document.body.style.overflow = '';
+ }
+
+ // ============================================================
+ // Sidebar/Desktop graph
+ // ============================================================
+
+ function setupDesktopGraph() {
+ const graphEl = document.querySelector('.app-graph');
+ if (!graphEl) return;
+ const canvas = document.createElement('canvas');
+ graphEl.appendChild(canvas);
+ requestAnimationFrame(() => {
+ graphState = buildGraph(canvas, graphEl);
+ });
+ }
+
+ // ============================================================
+ // Top-bar buttons (toolbar actions)
+ // ============================================================
+
+ function setupToolbar() {
+ document.addEventListener('click', (e) => {
+ const btn = e.target.closest('[data-action]');
+ if (!btn) return;
+ const action = btn.dataset.action;
+ if (action === 'toggle-tree') {
+ toggleTree();
+ } else if (action === 'toggle-graph') {
+ const isMobile = window.innerWidth < 768;
+ if (isMobile) openGraphModal();
+ else if (graphState) graphState.render();
+ } else if (action === 'focus-search') {
+ const s = document.getElementById('search');
+ if (s) s.focus();
+ }
+ });
+ }
+
+ // ============================================================
+ // Modal close handlers
+ // ============================================================
+
+ function setupModalClose() {
+ const closeBtn = document.querySelector('.graph-modal-close');
+ if (closeBtn) closeBtn.onclick = closeGraphModal;
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ const modal = document.querySelector('.graph-modal');
+ if (modal && modal.classList.contains('open')) closeGraphModal();
+ }
+ });
+ }
+
+ // ============================================================
+ // Backdrop
+ // ============================================================
+
+ function setupBackdrop() {
+ let backdrop = document.querySelector('.backdrop');
+ if (!backdrop) {
+ backdrop = document.createElement('div');
+ backdrop.className = 'backdrop';
+ document.body.appendChild(backdrop);
+ }
+ backdrop.onclick = () => {
+ const tree = document.querySelector('.app-tree');
+ if (tree) tree.classList.remove('open');
+ backdrop.classList.remove('open');
+ };
+ }
+
+ // ============================================================
+ // Utility
+ // ============================================================
+
+ function escapeHtml(str) {
+ return String(str).replace(/[&<>"']/g, (c) => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ })[c]);
+ }
+
+ // ============================================================
+ // Init
+ // ============================================================
+
+ function init() {
+ const treeEl = document.getElementById('tree');
+ if (treeEl && data.tree) renderTree(data.tree, treeEl);
+
+ setupSearch();
+ setupToolbar();
+ setupModalClose();
+ setupBackdrop();
+ setupBottomNav();
+ setupDesktopGraph();
+
+ renderTOC();
+ renderBacklinks();
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
\ No newline at end of file
diff --git a/generator.py b/generator.py
index 324a5ce..a52348a 100644
--- a/generator.py
+++ b/generator.py
@@ -242,13 +242,16 @@ PAGE_TEMPLATE = """
+
@@ -265,10 +268,25 @@ PAGE_TEMPLATE = """
+
+
+
+
+
Legend
+
Current page
+
Connected
+
Other notes
+
+
@@ -489,13 +507,19 @@ PWA_MANIFEST = """{
def write_assets(site_dir: Path) -> None:
- """Write static assets (CSS, JS, manifest)."""
+ """Copy static assets (CSS, JS, manifest) from repo to site dir."""
assets_dir = site_dir / "__"
assets_dir.mkdir(parents=True, exist_ok=True)
- (assets_dir / "style.css").write_text(CSS_PLACEHOLDER, encoding="utf-8")
- (assets_dir / "app.js").write_text(JS_PLACEHOLDER, encoding="utf-8")
- (assets_dir / "manifest.json").write_text(PWA_MANIFEST, encoding="utf-8")
- # Index page (redirect to first page or show all)
+
+ repo_root = Path(__file__).parent
+ for asset in ("style.css", "app.js", "manifest.json"):
+ src = repo_root / asset
+ if src.exists():
+ content = src.read_text(encoding="utf-8")
+ (assets_dir / asset).write_text(content, encoding="utf-8")
+ else:
+ log.warning(f"Asset not found in repo: {src}")
+ # index page (redirect to first page or show all)
index_html = """
Hermes Wiki
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..3315cb1
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,16 @@
+{
+ "name": "Hermes Wiki",
+ "short_name": "Wiki",
+ "description": "Lukas Huber's knowledge vault β mobile-first static wiki",
+ "start_url": "/__/index.html",
+ "display": "standalone",
+ "background_color": "#0a0a14",
+ "theme_color": "#0a0a14",
+ "icons": [
+ {
+ "src": "data:image/svg+xml,",
+ "sizes": "any",
+ "type": "image/svg+xml"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..8797ea9
--- /dev/null
+++ b/style.css
@@ -0,0 +1,685 @@
+/* Hermes Wiki β Touch-First CSS for Phase 2
+ *
+ * Design principles:
+ * - Bottom-Nav on mobile (3 tabs: Files, Search, Graph)
+ * - Hamburger-Tree always off-canvas on mobile, slide-in on click
+ * - Graph shown via FAB-style toggle β opens full-screen modal
+ * - Desktop: 3-panel layout (tree 280px, content flex, graph 320px)
+ * - Tablet (768-1023px): 2-panel (tree 220px, content flex), graph as bottom panel
+ * - Mobile (<768px): 1-panel (content only), tree/graph via toggles
+ * - Tap targets β₯ 44px, no accidental 300ms delay
+ * - Safe-area-insets for iOS Dynamic Island
+ * - Pull-to-refresh via overscroll-behavior: contain
+ */
+
+:root {
+ --bg-primary: #0a0a14;
+ --bg-secondary: #0e0e1a;
+ --bg-surface: #1a1a2e;
+ --bg-elevated: #252537;
+ --text-primary: #cdd6f4;
+ --text-secondary: #a6adc8;
+ --text-muted: #6c7086;
+ --accent: #FFD700;
+ --link: #FFD700;
+ --link-visited: #cba6f7;
+ --border: #313244;
+ --green: #a6e3a1;
+ --red: #f38ba8;
+ --blue: #89b4fa;
+ --teal: #94e2d5;
+ --sat: env(safe-area-inset-top, 0px);
+ --sab: env(safe-area-inset-bottom, 0px);
+ --sal: env(safe-area-inset-left, 0px);
+ --sar: env(safe-area-inset-right, 0px);
+ --tap-min: 44px;
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
+
+html, body {
+ height: 100%;
+ overflow: hidden;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif;
+ font-size: 15px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ overscroll-behavior: contain; /* pull-to-refresh only where intended */
+}
+
+body {
+ padding-top: var(--sat);
+ padding-left: var(--sal);
+ padding-right: var(--sar);
+ padding-bottom: var(--sab);
+}
+
+/* ============================================================
+ * Layout: Desktop (β₯1024px) β 3-panel
+ * ============================================================ */
+.app-shell {
+ display: grid;
+ grid-template-columns: 280px 1fr 320px;
+ grid-template-rows: 48px 1fr;
+ height: 100vh;
+ height: 100dvh;
+ width: 100%;
+ max-width: 100vw;
+}
+
+.app-header {
+ grid-column: 1 / -1;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ gap: 12px;
+ position: sticky;
+ top: 0;
+ z-index: 50;
+}
+
+.brand {
+ color: var(--accent);
+ font-weight: 700;
+ font-size: 14px;
+ letter-spacing: 0.5px;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+#search {
+ flex: 1;
+ max-width: 480px;
+ height: 36px;
+ padding: 0 12px 0 36px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text-primary);
+ font-size: 13px;
+ outline: none;
+ transition: border-color 0.15s;
+}
+#search:focus { border-color: var(--accent); }
+#search::placeholder { color: var(--text-muted); }
+
+.search-wrap { position: relative; flex: 1; max-width: 480px; }
+.search-wrap::before {
+ content: 'π';
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 13px;
+ pointer-events: none;
+ opacity: 0.5;
+}
+
+.icon-btn {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text-secondary);
+ width: 36px;
+ height: 36px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
+
+.app-tree {
+ grid-column: 1;
+ grid-row: 2;
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border);
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 8px 0;
+ font-size: 13px;
+}
+
+.app-content {
+ grid-column: 2;
+ grid-row: 2;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 32px 48px 64px;
+ max-width: 100%;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ hyphens: auto;
+}
+
+.app-graph {
+ grid-column: 3;
+ grid-row: 2;
+ background: var(--bg-secondary);
+ border-left: 1px solid var(--border);
+ overflow: hidden;
+ position: relative;
+ min-height: 0;
+}
+
+.app-graph canvas {
+ display: block;
+ width: 100% !important;
+ height: 100% !important;
+ cursor: grab;
+}
+.app-graph canvas:active { cursor: grabbing; }
+
+/* ============================================================
+ * Tree rendering
+ * ============================================================ */
+.tree-root { padding: 4px 0; }
+.tree-folder { margin: 1px 0; }
+.tree-folder-header {
+ display: flex;
+ align-items: center;
+ padding: 6px 12px;
+ gap: 6px;
+ cursor: pointer;
+ user-select: none;
+ color: var(--text-secondary);
+ font-weight: 500;
+ border-radius: 4px;
+ margin: 1px 4px;
+ min-height: var(--tap-min);
+ font-size: 13px;
+}
+.tree-folder-header:hover { background: rgba(255, 215, 0, 0.05); }
+.tree-folder-header.active { background: rgba(255, 215, 0, 0.08); color: var(--accent); }
+.tree-folder-header .arrow {
+ color: var(--text-muted);
+ font-size: 10px;
+ width: 12px;
+ display: inline-block;
+ transition: transform 0.15s;
+}
+.tree-folder.open > .tree-folder-header .arrow { transform: rotate(90deg); }
+.tree-children { padding-left: 12px; display: none; }
+.tree-folder.open > .tree-children { display: block; }
+
+.tree-file {
+ display: block;
+ padding: 6px 12px 6px 24px;
+ color: var(--text-primary);
+ text-decoration: none;
+ border-radius: 4px;
+ margin: 1px 4px;
+ min-height: var(--tap-min);
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.tree-file:hover { background: rgba(255, 215, 0, 0.05); color: var(--accent); }
+.tree-file.current {
+ background: rgba(255, 215, 0, 0.12);
+ color: var(--accent);
+ border-left: 3px solid var(--accent);
+ padding-left: 21px;
+}
+.tree-file-icon { margin-right: 6px; opacity: 0.5; }
+
+/* ============================================================
+ * Note content
+ * ============================================================ */
+.note { max-width: 920px; margin: 0 auto; }
+.meta-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 14px 18px;
+ margin-bottom: 24px;
+ font-size: 12px;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 6px 16px;
+}
+.meta-row {
+ display: flex;
+ gap: 8px;
+ align-items: baseline;
+ min-width: 0;
+}
+.meta-key {
+ color: var(--accent);
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.5px;
+ min-width: 70px;
+ flex-shrink: 0;
+}
+.meta-value {
+ color: var(--text-secondary);
+ word-break: break-word;
+ font-family: ui-monospace, 'SF Mono', Monaco, monospace;
+ font-size: 11px;
+}
+
+.note-body {
+ line-height: 1.7;
+ font-size: 16px;
+}
+.note-body h1 { font-size: 2em; margin: 0.6em 0 0.4em; padding-bottom: 8px; border-bottom: 1px solid var(--border); color: var(--text-primary); }
+.note-body h2 { font-size: 1.5em; margin: 1.5em 0 0.5em; color: var(--text-primary); border-bottom: 1px solid rgba(49,50,68,0.5); padding-bottom: 4px; }
+.note-body h3 { font-size: 1.25em; margin: 1.2em 0 0.4em; color: var(--text-primary); }
+.note-body h4 { font-size: 1.1em; margin: 1em 0 0.4em; color: var(--text-secondary); }
+.note-body p { margin: 0.8em 0; }
+.note-body ul, .note-body ol { margin: 0.5em 0; padding-left: 1.8em; }
+.note-body li { margin: 0.3em 0; }
+.note-body blockquote {
+ border-left: 3px solid var(--accent);
+ padding: 0.5em 1em;
+ margin: 1em 0;
+ background: rgba(255, 215, 0, 0.04);
+ color: var(--text-secondary);
+ font-style: italic;
+}
+.note-body code {
+ background: var(--bg-surface);
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-family: ui-monospace, 'SF Mono', Monaco, monospace;
+ font-size: 0.9em;
+ color: var(--teal);
+}
+.note-body pre {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ padding: 14px 16px;
+ border-radius: 8px;
+ overflow-x: auto;
+ margin: 1em 0;
+ font-size: 13px;
+ line-height: 1.5;
+}
+.note-body pre code {
+ background: transparent;
+ padding: 0;
+ color: var(--text-primary);
+ font-size: inherit;
+}
+.note-body table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1em 0;
+ font-size: 14px;
+ display: block;
+ overflow-x: auto;
+}
+.note-body th {
+ background: var(--bg-surface);
+ padding: 8px 12px;
+ text-align: left;
+ border: 1px solid var(--border);
+ font-weight: 600;
+ color: var(--accent);
+}
+.note-body td {
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+}
+.note-body tr:nth-child(even) { background: rgba(30, 30, 50, 0.3); }
+.note-body a {
+ color: var(--link);
+ text-decoration: none;
+ border-bottom: 1px solid rgba(255, 215, 0, 0.3);
+}
+.note-body a:hover { border-bottom-color: var(--accent); }
+.note-body a:visited { color: var(--link-visited); }
+.note-body .wikilink {
+ color: var(--blue);
+ border-bottom: 1px dashed var(--blue);
+}
+.note-body .wikilink:hover { color: var(--accent); border-bottom-color: var(--accent); }
+.note-body .wikilink-missing {
+ color: var(--text-muted);
+ border-bottom: 1px dotted var(--text-muted);
+ text-decoration: line-through;
+}
+.note-body img { max-width: 100%; height: auto; border-radius: 6px; margin: 1em 0; }
+.note-body hr { border: none; border-top: 1px solid var(--border); margin: 2em 0; }
+.note-body .headerlink { color: var(--text-muted); margin-left: 8px; font-size: 0.7em; text-decoration: none; opacity: 0; transition: opacity 0.15s; }
+.note-body h1:hover .headerlink, .note-body h2:hover .headerlink, .note-body h3:hover .headerlink { opacity: 1; }
+
+/* TOC sidebar (in note footer area) */
+.note-toc {
+ margin-top: 48px;
+ padding: 16px 0;
+ border-top: 1px solid var(--border);
+ font-size: 13px;
+}
+.note-toc-title {
+ color: var(--accent);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ margin-bottom: 8px;
+}
+.note-toc ul { list-style: none; padding-left: 0; }
+.note-toc li { margin: 3px 0; }
+.note-toc a {
+ color: var(--text-secondary);
+ text-decoration: none;
+ border-bottom: none;
+}
+.note-toc a:hover { color: var(--accent); }
+.note-toc ul ul { padding-left: 16px; }
+
+/* Backlinks section */
+.note-backlinks {
+ margin-top: 24px;
+ padding: 16px 0;
+ border-top: 1px solid var(--border);
+ font-size: 13px;
+}
+.note-backlinks-title {
+ color: var(--accent);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ margin-bottom: 8px;
+}
+.note-backlinks ul { list-style: none; padding-left: 0; }
+.note-backlinks li { margin: 4px 0; }
+.note-backlinks a { color: var(--blue); border-bottom: 1px dashed var(--blue); }
+
+.note-footer {
+ margin-top: 32px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+ font-size: 11px;
+ color: var(--text-muted);
+ font-family: ui-monospace, 'SF Mono', Monaco, monospace;
+}
+
+/* ============================================================
+ * Search dropdown
+ * ============================================================ */
+.search-results {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-top: none;
+ border-radius: 0 0 6px 6px;
+ max-height: 60vh;
+ overflow-y: auto;
+ display: none;
+ z-index: 100;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
+}
+.search-results.open { display: block; }
+.search-result {
+ display: block;
+ padding: 10px 16px;
+ color: var(--text-primary);
+ text-decoration: none;
+ border-bottom: 1px solid rgba(49, 50, 68, 0.3);
+ min-height: var(--tap-min);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.search-result:hover, .search-result.active {
+ background: rgba(255, 215, 0, 0.08);
+}
+.search-result-title {
+ font-weight: 600;
+ font-size: 13px;
+}
+.search-result-path {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-family: ui-monospace, monospace;
+}
+.search-result-tag {
+ display: inline-block;
+ background: var(--bg-surface);
+ color: var(--accent);
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ margin-right: 4px;
+}
+.search-empty {
+ padding: 16px;
+ color: var(--text-muted);
+ font-size: 12px;
+ text-align: center;
+}
+
+/* ============================================================
+ * Graph modal (full-screen on mobile)
+ * ============================================================ */
+.graph-modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--bg-primary);
+ z-index: 200;
+ flex-direction: column;
+ padding-top: var(--sat);
+ padding-left: var(--sal);
+ padding-right: var(--sar);
+ padding-bottom: var(--sab);
+}
+.graph-modal.open { display: flex; }
+.graph-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ height: 48px;
+ flex-shrink: 0;
+}
+.graph-modal-title {
+ color: var(--accent);
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+}
+.graph-modal-close {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text-secondary);
+ width: 36px;
+ height: 36px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 18px;
+}
+.graph-modal-close:hover { color: var(--accent); border-color: var(--accent); }
+.graph-modal-canvas {
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+}
+.graph-modal-canvas canvas {
+ display: block;
+ width: 100% !important;
+ height: 100% !important;
+ cursor: grab;
+}
+.graph-modal-canvas canvas:active { cursor: grabbing; }
+
+/* Graph legend (small floating panel) */
+.graph-legend {
+ position: absolute;
+ bottom: 12px;
+ left: 12px;
+ background: rgba(14, 14, 26, 0.92);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 8px 12px;
+ font-size: 11px;
+ color: var(--text-secondary);
+ z-index: 10;
+}
+.graph-legend-title {
+ color: var(--accent);
+ font-weight: 700;
+ font-size: 10px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ margin-bottom: 4px;
+}
+.graph-legend-row { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
+.graph-legend-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+/* ============================================================
+ * Bottom navigation (mobile only)
+ * ============================================================ */
+.app-bottom-nav {
+ display: none;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border);
+ z-index: 150;
+ padding-bottom: var(--sab);
+}
+.app-bottom-nav-inner {
+ display: flex;
+ justify-content: space-around;
+ height: 56px;
+}
+.app-bottom-nav button {
+ flex: 1;
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 11px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ cursor: pointer;
+ min-height: var(--tap-min);
+ padding: 4px 0;
+}
+.app-bottom-nav button:hover, .app-bottom-nav button.active { color: var(--accent); }
+.app-bottom-nav button span.icon { font-size: 18px; line-height: 1; }
+
+/* ============================================================
+ * Tablet (768px-1023px) β 2-panel + bottom graph
+ * ============================================================ */
+@media (max-width: 1023px) {
+ .app-shell {
+ grid-template-columns: 220px 1fr;
+ grid-template-rows: 48px 1fr 240px;
+ }
+ .app-tree {
+ grid-column: 1;
+ grid-row: 2 / 4;
+ border-right: 1px solid var(--border);
+ }
+ .app-content {
+ grid-column: 2;
+ grid-row: 2;
+ padding: 24px 32px 32px;
+ }
+ .app-graph {
+ grid-column: 1 / -1;
+ grid-row: 3;
+ border-left: none;
+ border-top: 1px solid var(--border);
+ height: 240px;
+ }
+}
+
+/* ============================================================
+ * Mobile (<768px) β single column + bottom-nav
+ * ============================================================ */
+@media (max-width: 767px) {
+ .app-shell {
+ grid-template-columns: 1fr;
+ grid-template-rows: 48px 1fr;
+ }
+ .app-header { padding: 0 12px; gap: 8px; }
+ .app-header .icon-btn[data-action="toggle-tree"],
+ .app-header .icon-btn[data-action="toggle-graph"] {
+ display: none; /* hide redundant buttons, use bottom-nav */
+ }
+ .search-wrap { max-width: none; }
+
+ .app-tree {
+ position: fixed;
+ top: 48px;
+ top: calc(48px + var(--sat));
+ left: 0;
+ bottom: 56px;
+ bottom: calc(56px + var(--sab));
+ width: 85vw;
+ max-width: 320px;
+ transform: translateX(-100%);
+ transition: transform 0.25s ease-in-out;
+ z-index: 250;
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border);
+ display: block;
+ }
+ .app-tree.open { transform: translateX(0); }
+
+ .app-content {
+ grid-column: 1;
+ grid-row: 2;
+ padding: 16px 20px 72px; /* bottom-nav space + safe area */
+ }
+
+ .app-graph { display: none; }
+
+ .app-bottom-nav { display: block; }
+}
+
+/* Backdrop for mobile tree/graph overlay */
+.backdrop {
+ display: none;
+ position: fixed;
+ top: 48px;
+ top: calc(48px + var(--sat));
+ left: 0;
+ right: 0;
+ bottom: 56px;
+ bottom: calc(56px + var(--sab));
+ background: rgba(0, 0, 0, 0.6);
+ z-index: 200;
+}
+.backdrop.open { display: block; }
+
+/* ============================================================
+ * Inline highlight for hash anchors
+ * ============================================================ */
+:target { scroll-margin-top: 60px; }
+:target h1, :target h2, :target h3 {
+ background: rgba(255, 215, 0, 0.08);
+ transition: background 0.6s ease-out;
+}
\ No newline at end of file