Phase 2: Touch-First CSS + Touch-First JS + Graph-Modal

Touch-First Design (style.css, 17 KB):
- Mobile-First CSS mit Safe-Area-Insets für iOS Dynamic Island
- Bottom-Nav (Files / Search / Graph) auf <768px
- Hamburger-Tree slidet von links rein mit Backdrop
- Graph-Modal als Full-Screen-Overlay mit Title-Bar + Close
- Tap-Targets ≥ 44px (--tap-min)
- Tablet-Mode (768-1023px): 2-Panel + Bottom-Graph 240px
- Desktop (≥1024px): 3-Panel mit Tree 280px / Graph 320px
- Touch-Action-Manipulation verhindert 300ms-Tap-Delay
- Word-wrap + overflow-wrap für Mobile-Content
- Frontmatter-Card als Grid-Layout
- TOC + Backlinks Styling

Touch-First JS (app.js, 21 KB):
- Tree-Render mit auf-/zuklappbaren Ordnern (auto-open concepts/entities)
- Search mit Live-Dropdown, Keyboard-Navigation (↑↓ Enter Esc)
- 3D Graph in Three.js, KEIN Auto-Rotation, drag-to-rotate + wheel-zoom
- Click-zentriert: Click auf Node → navigiert zur Page
- Modal-Close via X-Button oder Escape
- Touch/Click-Handler: pointerdown/move/up + Raycaster
- Backlinks-Section aus data.backlinks
- TOC aus h2/h3-Headings der aktuellen Page
- Hash-anchor highlighting via :target CSS

Template-Patch (generator.py):
- Search-Wrap-Container für absolute Dropdown-Position
- Bottom-Nav mit inner-Flex
- Graph-Modal-Container
- Three.js-CDN-Script im HEAD

Assets werden jetzt aus dem Repo kopiert (style.css, app.js, manifest.json)
statt eingebettete Placeholder-Strings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-19 17:20:34 +00:00
co-authored by Claude
parent b5631986e6
commit d0e4e8ab9e
4 changed files with 1373 additions and 9 deletions
+639
View File
@@ -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 = `<span class="arrow">▶</span><span>${escapeHtml(folder.name)}</span>`;
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 = `<span class="tree-file-icon">📄</span><span>${escapeHtml(file.title)}</span>`;
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 = '<div class="search-empty">Keine Treffer</div>';
} 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 => `<span class="search-result-tag">${escapeHtml(t)}</span>`).join('');
a.innerHTML = `<span class="search-result-title">${escapeHtml(r.title)}</span><span class="search-result-path">${tagsHtml}${escapeHtml(r.slug)}</span>`;
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 = [
'<div class="note-backlinks-title">Backlinks (' + links.length + ')</div>',
'<ul>' + links.map(slug => {
const page = data.pages.find(p => p.slug === slug);
const title = page ? page.title : slug;
return `<li><a href="/${slug}.html">${escapeHtml(title)}</a></li>`;
}).join('') + '</ul>'
].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 `<li style="margin-left: ${(level - 2) * 16}px"><a href="#${id}">${escapeHtml(text)}</a></li>`;
}).join('');
container.innerHTML = `<div class="note-toc-title">Inhalt</div><ul>${items}</ul>`;
}
// ============================================================
// 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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[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();
}
})();
+33 -9
View File
@@ -242,13 +242,16 @@ PAGE_TEMPLATE = """<!DOCTYPE html>
<link rel="stylesheet" href="/__/style.css">
<link rel="manifest" href="/__/manifest.json">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
</head>
<body class="page-{kind}">
<div class="app-shell">
<header class="app-header">
<button class="icon-btn menu-toggle" aria-label="Toggle tree" data-action="toggle-tree">☰</button>
<a href="/__/index.html" class="brand">📓 Hermes Wiki</a>
<input type="search" id="search" placeholder="Suchen…" aria-label="Search notes">
<div class="search-wrap">
<input type="search" id="search" placeholder="Suchen…" aria-label="Search notes">
</div>
<button class="icon-btn graph-toggle" aria-label="Toggle graph" data-action="toggle-graph">⊕</button>
</header>
<aside class="app-tree" id="tree" aria-label="File tree"></aside>
@@ -265,10 +268,25 @@ PAGE_TEMPLATE = """<!DOCTYPE html>
</main>
<aside class="app-graph" id="graph" aria-label="3D graph"></aside>
<nav class="app-bottom-nav" aria-label="Bottom navigation">
<button data-action="toggle-tree">☰<br><small>Files</small></button>
<button data-action="focus-search">🔍<br><small>Search</small></button>
<button data-action="toggle-graph">⊕<br><small>Graph</small></button>
<div class="app-bottom-nav-inner">
<button data-action="toggle-tree"><span class="icon">☰</span><small>Files</small></button>
<button data-action="focus-search"><span class="icon">🔍</span><small>Search</small></button>
<button data-action="toggle-graph"><span class="icon">⊕</span><small>Graph</small></button>
</div>
</nav>
<div class="graph-modal" aria-label="3D graph (full screen)">
<div class="graph-modal-header">
<span class="graph-modal-title">GRAPH VIEW · Drag to rotate · Tap a node to navigate</span>
<button class="graph-modal-close" aria-label="Close">×</button>
</div>
<div class="graph-modal-canvas"><canvas></canvas></div>
<div class="graph-legend">
<div class="graph-legend-title">Legend</div>
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#FFD700"></span>Current page</div>
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#FFD700;opacity:0.8"></span>Connected</div>
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#6c7086;opacity:0.5"></span>Other notes</div>
</div>
</div>
</div>
<script src="/__/data.js"></script>
<script src="/__/app.js"></script>
@@ -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 = """<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Hermes Wiki</title>
<link rel="stylesheet" href="/__/style.css">
+16
View File
@@ -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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
+685
View File
@@ -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;
}