diff --git a/public/components/breadcrumb.js b/public/components/breadcrumb.js
new file mode 100644
index 0000000..85a55ce
--- /dev/null
+++ b/public/components/breadcrumb.js
@@ -0,0 +1,35 @@
+// Inline stylised breadcrumb: Space › parent page › … › current.
+// Walks page.parent_id upward (capped); fills in async, returns the element now.
+import { el } from '../dom.js';
+import { api } from '../api.js';
+
+export function breadcrumb(page) {
+ const nav = el('nav', { class: 'crumbs' });
+ (async () => {
+ const parts = [];
+ try {
+ const sp = await api.get('/api/spaces/' + page.space_id);
+ parts.push({ label: sp.name, href: '#/space/' + sp.id });
+ } catch { /* */ }
+
+ const chain = [];
+ let pid = page.parent_id, guard = 0;
+ while (pid && guard++ < 8) {
+ try {
+ const par = await api.get('/api/pages/' + pid);
+ chain.unshift({ label: par.title, href: '#/page/' + par.id });
+ pid = par.parent_id;
+ } catch { break; }
+ }
+ parts.push(...chain, { label: page.title, href: null });
+
+ nav.replaceChildren();
+ parts.forEach((p, i) => {
+ if (i) nav.appendChild(el('span', { class: 'crumb-sep' }, '›'));
+ nav.appendChild(p.href
+ ? el('a', { class: 'crumb', href: p.href }, p.label)
+ : el('span', { class: 'crumb current' }, p.label));
+ });
+ })();
+ return nav;
+}
diff --git a/public/components/export_menu.js b/public/components/export_menu.js
new file mode 100644
index 0000000..804687c
--- /dev/null
+++ b/public/components/export_menu.js
@@ -0,0 +1,68 @@
+// Export dropdown — Markdown / Plain text / Web page / PDF. Client-side, no deps
+// beyond the bundled marked + DOMPurify. getContent() → { title, md }.
+import { el } from '../dom.js';
+import { marked } from '../vendor/marked.esm.js';
+import DOMPurify from '../vendor/purify.es.mjs';
+
+function download(name, text, mime) {
+ const url = URL.createObjectURL(new Blob([text], { type: mime }));
+ const a = el('a', { href: url, download: name });
+ document.body.appendChild(a); a.click(); a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+
+function toPlain(md) {
+ return md.replace(/```[\s\S]*?```/g, m => m.replace(/```/g, ''))
+ .replace(/^#{1,6}\s+/gm, '').replace(/\*\*|__|\*|_|`|~~/g, '')
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^>\s?/gm, '');
+}
+
+function htmlDoc(title, md) {
+ const body = DOMPurify.sanitize(marked.parse(md));
+ return `
+${title}
+${body}`;
+}
+
+export function exportMenu({ getContent, filenameBase }) {
+ const wrap = el('div', { class: 'exp-menu' });
+
+ async function run(kind) {
+ wrap.classList.remove('open');
+ const { title, md } = await getContent();
+ const base = (filenameBase || title || 'export').replace(/[^\w.-]+/g, '-').replace(/(^-|-$)/g, '') || 'export';
+ if (kind === 'md') download(base + '.md', md, 'text/markdown');
+ else if (kind === 'txt') download(base + '.txt', toPlain(md), 'text/plain');
+ else if (kind === 'html') download(base + '.html', htmlDoc(title, md), 'text/html');
+ else if (kind === 'pdf') {
+ const w = window.open('', '_blank');
+ if (!w) return;
+ w.document.write(htmlDoc(title, md)); w.document.close(); w.focus();
+ setTimeout(() => w.print(), 300);
+ }
+ }
+
+ const btn = el('button', {
+ class: 'ghost',
+ onclick: (e) => { e.stopPropagation(); wrap.classList.toggle('open'); }
+ }, 'Export ▾');
+
+ const list = el('div', { class: 'exp-list' },
+ el('button', { onclick: () => run('md') }, 'Markdown (.md)'),
+ el('button', { onclick: () => run('txt') }, 'Plain text (.txt)'),
+ el('button', { onclick: () => run('html') }, 'Web page (.html)'),
+ el('button', { onclick: () => run('pdf') }, 'PDF (print)')
+ );
+
+ wrap.append(btn, list);
+ document.addEventListener('click', () => wrap.classList.remove('open'));
+ return wrap;
+}
diff --git a/public/style.css b/public/style.css
index 612ea3b..7b6d9f8 100644
--- a/public/style.css
+++ b/public/style.css
@@ -160,6 +160,26 @@ button.ghost:hover { color: var(--text); border-color: var(--accent-dim); }
.term-title { font-family: var(--font-display); color: var(--accent); letter-spacing: 0.08em; font-size: 14px; }
.term-frame { width: 100%; height: calc(100vh - 100px); border: 1px solid var(--border); border-radius: 6px; background: var(--bg); display: block; }
+/* Doc/space header bar: back + breadcrumb on the left, export on the right. */
+.doc-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
+.doc-head-left { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; flex-wrap: wrap; }
+.doc-head .back-btn { margin-bottom: 0; }
+.doc-head .exp-menu { margin-left: auto; }
+
+/* Breadcrumb: Space › parent › current */
+.crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; }
+.crumb { color: var(--muted); text-decoration: none; }
+.crumb:hover { color: var(--accent); }
+.crumb.current { color: var(--text); }
+.crumb-sep { color: var(--accent-dim); }
+
+/* Export dropdown */
+.exp-menu { position: relative; }
+.exp-list { position: absolute; right: 0; top: calc(100% + 4px); background: var(--panel-2); border: 1px solid var(--border); border-radius: 5px; padding: 4px; min-width: 168px; display: none; z-index: 40; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5); }
+.exp-menu.open .exp-list { display: block; }
+.exp-list button { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 7px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; font-family: var(--font-ui); }
+.exp-list button:hover { background: var(--accent-soft); color: var(--accent); }
+
/* modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal {
diff --git a/public/views/page.js b/public/views/page.js
index 2e02df0..2f0eb77 100644
--- a/public/views/page.js
+++ b/public/views/page.js
@@ -3,6 +3,8 @@ import { api } from '../api.js';
import { el, mount } from '../dom.js';
import { markdownEditor } from '../components/markdown_editor.js';
import { backButton } from '../components/backbtn.js';
+import { breadcrumb } from '../components/breadcrumb.js';
+import { exportMenu } from '../components/export_menu.js';
export async function render(main, ctx) {
const id = ctx.params.id;
@@ -29,7 +31,10 @@ export async function render(main, ctx) {
);
mount(main,
- backButton(),
+ el('div', { class: 'doc-head' },
+ el('div', { class: 'doc-head-left' }, backButton(), breadcrumb(page)),
+ exportMenu({ filenameBase: page.slug, getContent: async () => ({ title: page.title, md: page.body_md || '' }) })
+ ),
el('h1', { class: 'view-h1' }, page.title),
el('p', { class: 'view-sub muted' }, '/' + page.slug),
editor,
diff --git a/public/views/space.js b/public/views/space.js
index 3df4d09..e9d8ef7 100644
--- a/public/views/space.js
+++ b/public/views/space.js
@@ -2,6 +2,7 @@
// pages & references table below (all pages; refs up to the API max).
import { api } from '../api.js';
import { el, mount } from '../dom.js';
+import { exportMenu } from '../components/export_menu.js';
function projItem(p) {
return el('li', {},
@@ -51,7 +52,17 @@ export async function render(main, ctx) {
];
mount(main,
- el('h1', { class: 'view-h1' }, space.name),
+ el('div', { class: 'doc-head' },
+ el('h1', { class: 'view-h1', style: { margin: '0' } }, space.name),
+ exportMenu({
+ filenameBase: 'space-' + (space.slug || space.name),
+ getContent: async () => {
+ const full = await Promise.all(pages.map(p => api.get('/api/pages/' + p.id).catch(() => null)));
+ const md = full.filter(Boolean).map(p => `# ${p.title}\n\n${p.body_md || ''}`).join('\n\n---\n\n');
+ return { title: space.name, md };
+ }
+ })
+ ),
el('p', { class: 'view-sub' }, space.description || el('span', { class: 'muted' }, 'No description.')),
// Top: Projects + Open tasks, side by side.