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:
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[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();
|
||||
}
|
||||
})();
|
||||
+32
-8
@@ -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>
|
||||
<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">
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user