/**
 * <folk-notes-app> — notebook and note management.
 *
 * Browse notebooks, create/edit notes with rich text (Tiptap),
 * search, tag management.
 *
 * Notebook list: REST (GET /api/notebooks)
 * Notebook detail + notes: Automerge sync via WebSocket
 * Search: REST (GET /api/notes?q=...)
 */

import * as Automerge from '@automerge/automerge';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Placeholder from '@tiptap/extension-placeholder';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import Typography from '@tiptap/extension-typography';
import Underline from '@tiptap/extension-underline';
import { common, createLowlight } from 'lowlight';
import { createSlashCommandPlugin } from './slash-command';
import type { ImportExportDialog } from './import-export-dialog';

const lowlight = createLowlight(common);

/** Inline SVG icons for toolbar buttons (16×16, stroke-based, currentColor) */
const ICONS: Record<string, string> = {
	bold: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5h5a2.5 2.5 0 0 1 0 5H4zM4 7.5h5.5a2.5 2.5 0 0 1 0 5H4z"/></svg>',
	italic: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="2.5" x2="6" y2="13.5"/><line x1="6.5" y1="2.5" x2="11.5" y2="2.5"/><line x1="4.5" y1="13.5" x2="9.5" y2="13.5"/></svg>',
	underline: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5v5a4 4 0 0 0 8 0v-5"/><line x1="3" y1="14" x2="13" y2="14"/></svg>',
	strike: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4.5c-.5-1-1.8-2-3.5-2C5.5 2.5 4 3.8 4 5.3c0 1 .5 1.7 1.5 2.2"/><line x1="3" y1="8" x2="13" y2="8"/><path d="M5 11c.5 1.2 1.8 2.5 3.5 2.5 2 0 3.5-1.3 3.5-2.8 0-.7-.3-1.3-.8-1.7"/></svg>',
	code: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="5.5 4 2 8 5.5 12"/><polyline points="10.5 4 14 8 10.5 12"/></svg>',
	bulletList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="6" y1="4" x2="14" y2="4"/><line x1="6" y1="8" x2="14" y2="8"/><line x1="6" y1="12" x2="14" y2="12"/><circle cx="3" cy="4" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="8" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="12" r="1" fill="currentColor" stroke="none"/></svg>',
	orderedList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="14" y2="12"/><text x="1.5" y="5.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">1</text><text x="1.5" y="9.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">2</text><text x="1.5" y="13.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">3</text></svg>',
	taskList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="5" height="5" rx="1"/><polyline points="3.5 4.5 4.5 5.5 6 3.5"/><line x1="9" y1="4.5" x2="14" y2="4.5"/><rect x="2" y="9" width="5" height="5" rx="1"/><line x1="9" y1="11.5" x2="14" y2="11.5"/></svg>',
	blockquote: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="2" x2="3" y2="14"/><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="12" y2="12"/></svg>',
	codeBlock: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="5 6 3.5 8 5 10"/><polyline points="11 6 12.5 8 11 10"/><line x1="9" y1="5" x2="7" y2="11"/></svg>',
	horizontalRule: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="2" y1="8" x2="14" y2="8"/><circle cx="4" cy="8" r="0.5" fill="currentColor"/><circle cx="8" cy="8" r="0.5" fill="currentColor"/><circle cx="12" cy="8" r="0.5" fill="currentColor"/></svg>',
	link: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 9.5a3 3 0 0 0 4.2.3l2-2a3 3 0 0 0-4.2-4.3L7 4.8"/><path d="M9.5 6.5a3 3 0 0 0-4.2-.3l-2 2a3 3 0 0 0 4.2 4.3L9 11.2"/></svg>',
	image: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="13" height="11" rx="2"/><circle cx="5.5" cy="6" r="1.5"/><path d="M14.5 10.5l-3.5-3.5-5 5"/></svg>',
	undo: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 6 2 8 4 10"/><path d="M2 8h8a4 4 0 0 1 0 8H8"/></svg>',
	redo: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="12 6 14 8 12 10"/><path d="M14 8H6a4 4 0 0 0 0 8h2"/></svg>',
};

interface Notebook {
	id: string;
	title: string;
	description: string;
	cover_color: string;
	note_count: string;
	updated_at: string;
}

interface Note {
	id: string;
	title: string;
	content: string;
	content_plain: string;
	content_format?: 'html' | 'tiptap-json';
	type: string;
	tags: string[] | null;
	is_pinned: boolean;
	created_at: string;
	updated_at: string;
}

/** Shape of Automerge notebook doc (matches PG→Automerge migration) */
interface NotebookDoc {
	meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number };
	notebook: {
		id: string; title: string; slug: string; description: string;
		coverColor: string; isPublic: boolean; createdAt: number; updatedAt: number;
	};
	items: Record<string, {
		id: string; notebookId: string; title: string; content: string;
		contentPlain: string; contentFormat?: string; type: string; tags: string[]; isPinned: boolean;
		sortOrder: number; createdAt: number; updatedAt: number;
	}>;
}

class FolkNotesApp extends HTMLElement {
	private shadow!: ShadowRoot;
	private space = "";
	private view: "notebooks" | "notebook" | "note" = "notebooks";
	private notebooks: Notebook[] = [];
	private selectedNotebook: (Notebook & { notes: Note[] }) | null = null;
	private selectedNote: Note | null = null;
	private searchQuery = "";
	private searchResults: Note[] = [];
	private loading = false;
	private error = "";

	// Zone-based rendering
	private navZone!: HTMLDivElement;
	private contentZone!: HTMLDivElement;
	private metaZone!: HTMLDivElement;

	// Tiptap editor
	private editor: Editor | null = null;
	private editorNoteId: string | null = null;
	private isRemoteUpdate = false;
	private editorUpdateTimer: ReturnType<typeof setTimeout> | null = null;

	// Automerge sync state
	private ws: WebSocket | null = null;
	private doc: Automerge.Doc<NotebookDoc> | null = null;
	private syncState: Automerge.SyncState = Automerge.initSyncState();
	private subscribedDocId: string | null = null;
	private syncConnected = false;
	private pingInterval: ReturnType<typeof setInterval> | null = null;

	// ── Demo data ──
	private demoNotebooks: (Notebook & { notes: Note[] })[] = [];

	constructor() {
		super();
		this.shadow = this.attachShadow({ mode: "open", delegatesFocus: true });
	}

	connectedCallback() {
		this.space = this.getAttribute("space") || "demo";
		this.setupShadow();
		if (this.space === "demo") { this.loadDemoData(); return; }
		this.connectSync();
		this.loadNotebooks();
	}

	private setupShadow() {
		const style = document.createElement('style');
		style.textContent = this.getStyles();
		this.navZone = document.createElement('div');
		this.navZone.id = 'nav-zone';
		this.contentZone = document.createElement('div');
		this.contentZone.id = 'content-zone';
		this.metaZone = document.createElement('div');
		this.metaZone.id = 'meta-zone';
		this.shadow.appendChild(style);
		this.shadow.appendChild(this.navZone);
		this.shadow.appendChild(this.contentZone);
		this.shadow.appendChild(this.metaZone);
	}

	// ── Demo data ──

	private loadDemoData() {
		const now = Date.now();
		const hour = 3600000;
		const day = 86400000;

		const tripPlanningNotes: Note[] = [
			{
				id: "demo-note-1", title: "Pre-trip Preparation",
				content: `<h2>Pre-trip Preparation</h2><h3>Flights &amp; Transfers</h3><ul><li><strong>Jul 6</strong>: Fly Geneva, shuttle to Chamonix (~1.5h)</li><li><strong>Jul 14</strong>: Train Zermatt to Dolomites (Bernina Express, ~6h scenic route)</li><li><strong>Jul 20</strong>: Fly home from Innsbruck</li></ul><blockquote><p>Book the Aiguille du Midi cable car tickets at least 2 weeks in advance -- they sell out fast in July.</p></blockquote><h3>Travel Documents</h3><ol><li>Passports (valid 6+ months)</li><li>EU health insurance cards (EHIC)</li><li>Travel insurance policy (ref: WA-2026-7891)</li><li>Hut reservation confirmations (printed copies)</li><li>Drone registration for Italy</li></ol><h3>Budget Overview</h3><p>Total budget: <strong>EUR 4,000</strong> across 4 travelers.</p><pre><code>Transport:     EUR  800 (20%)
Accommodation: EUR 1200 (30%)
Activities:    EUR 1000 (25%)
Food:          EUR  600 (15%)
Gear:          EUR  400 (10%)</code></pre><p><em>Maya is tracking expenses in rFlows. Current spend: EUR 1,203.</em></p>`,
				content_plain: "Pre-trip preparation checklist covering flights, transfers, travel documents, and budget overview for the Alpine Explorer 2026 trip.",
				content_format: 'html',
				type: "NOTE", tags: ["planning", "budget", "transport"], is_pinned: true,
				created_at: new Date(now - 14 * day).toISOString(), updated_at: new Date(now - hour).toISOString(),
			},
			{
				id: "demo-note-2", title: "Accommodation Research",
				content: `<h2>Accommodation Research</h2><h3>Chamonix (Jul 6-10)</h3><ul><li><strong>Refuge du Lac Blanc</strong> -- Jul 7, 4 beds, conf #LB2026-234</li><li>Airbnb in town for other nights (~EUR 120/night for 4 pax)</li><li>Consider Hotel Le Morgane if Airbnb falls through</li></ul><h3>Zermatt (Jul 10-14)</h3><ul><li><strong>Hornlihutte</strong> (Matterhorn base) -- Waitlisted for Jul 12</li><li>Main accommodation: Apartment near Bahnhofstrasse</li><li>Car-free village, arrive by Glacier Express</li></ul><blockquote><p>Zermatt is expensive. Budget EUR 80-100pp/night minimum. The apartment saves us about 40% vs hotels.</p></blockquote><h3>Dolomites (Jul 14-20)</h3><ul><li><strong>Rifugio Locatelli</strong> -- Jul 15, 4 beds, conf #TRE2026-089</li><li>Val Gardena base: Ortisei area</li><li>Look for agriturismo options for authentic experience</li></ul>`,
				content_plain: "Accommodation research for all three destinations: Chamonix, Zermatt, and Dolomites. Includes confirmed bookings, waitlists, and budget estimates.",
				content_format: 'html',
				type: "NOTE", tags: ["accommodation", "budget"], is_pinned: false,
				created_at: new Date(now - 12 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(),
			},
			{
				id: "demo-note-3", title: "Activity Planning",
				content: `<h2>Activity Planning</h2><h3>Hiking Routes</h3><ul><li><strong>Lac Blanc</strong> (Jul 7) -- Acclimatization hike, ~6h round trip, 1000m elevation gain.</li><li><strong>Gornergrat Sunrise</strong> (Jul 11) -- Take the first train up at 7am, hike down.</li><li><strong>Matterhorn Base Camp</strong> (Jul 12) -- Full day trek to Hornlihutte. 1500m gain.</li><li><strong>Tre Cime di Lavaredo</strong> (Jul 15) -- Classic loop, ~4h.</li><li><strong>Seceda Ridgeline</strong> (Jul 17) -- Gondola up, ridge walk, hike down to Ortisei.</li></ul><h3>Adventure Activities</h3><ol><li><strong>Via Ferrata at Aiguille du Midi</strong> (Jul 8) -- Rent harness + lanyard + helmet, ~EUR 25/day</li><li><strong>Paragliding over Zermatt</strong> (Jul 13) -- Tandem flights ~EUR 180pp</li><li><strong>Kayaking at Lago di Braies</strong> (Jul 16) -- Turquoise glacial lake, ~EUR 15/hour</li></ol><h3>Rest Days</h3><ul><li>Jul 9: Explore Chamonix town, gear shopping</li><li>Jul 19: Free day before flying home, packing</li></ul>`,
				content_plain: "Detailed activity planning including hiking routes with difficulty ratings, adventure activities with costs, and rest day plans.",
				content_format: 'html',
				type: "NOTE", tags: ["hiking", "activities", "adventure"], is_pinned: false,
				created_at: new Date(now - 10 * day).toISOString(), updated_at: new Date(now - 5 * hour).toISOString(),
			},
			{
				id: "demo-note-4", title: "Gear Research",
				content: `<h2>Gear Research</h2><h3>Via Ferrata Kit</h3><p>Need harness + lanyard + helmet. Can rent in Chamonix for ~EUR 25/day per person.</p><h3>Camera &amp; Drone</h3><ul><li>Bring the <strong>DJI Mini 4 Pro</strong> for Tre Cime and Seceda</li><li>Check Italian drone regulations! Need ENAC registration for flights over 250g</li><li>ND filters for long exposure water shots at Lago di Braies</li><li>Extra batteries (3x) -- cold altitude drains them fast</li></ul><h3>Personal Gear Checklist</h3><ul><li>Hiking boots (broken in!)</li><li>Rain jacket (waterproof, not just resistant)</li><li>Headlamp + spare batteries</li><li>Trekking poles (collapsible for flights)</li><li>Sunscreen SPF 50 + lip balm</li><li>Wool base layers for hut nights</li></ul>`,
				content_plain: "Gear research including Via Ferrata rental, camera and drone regulations, shared group gear status, and personal gear checklist.",
				content_format: 'html',
				type: "NOTE", tags: ["gear", "equipment", "budget"], is_pinned: false,
				created_at: new Date(now - 8 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(),
			},
			{
				id: "demo-note-5", title: "Emergency Contacts & Safety",
				content: `<h2>Emergency Contacts &amp; Safety</h2><h3>Emergency Numbers</h3><ul><li><strong>France</strong>: 112 (EU general), PGHM Mountain Rescue: +33 4 50 53 16 89</li><li><strong>Switzerland</strong>: 1414 (REGA air rescue), 144 (ambulance)</li><li><strong>Italy</strong>: 118 (medical), 112 (general emergency)</li></ul><h3>Insurance</h3><ul><li>Policy #: <strong>WA-2026-7891</strong></li><li>Emergency line: +1-800-555-0199</li><li>Covers: mountain rescue, helicopter evacuation, medical repatriation</li></ul><h3>Altitude Sickness Protocol</h3><ol><li>Acclimatize in Chamonix (1,035m) for 2 days before going high</li><li>Stay hydrated -- minimum 3L water per day above 2,500m</li><li>Watch for symptoms: headache, nausea, dizziness</li><li>Descend immediately if symptoms worsen</li></ol>`,
				content_plain: "Emergency contacts for France, Switzerland, and Italy. Insurance details, altitude sickness protocol, weather contingency plans.",
				content_format: 'html',
				type: "NOTE", tags: ["safety", "emergency", "contacts"], is_pinned: false,
				created_at: new Date(now - 7 * day).toISOString(), updated_at: new Date(now - 6 * hour).toISOString(),
			},
			{
				id: "demo-note-6", title: "Photo Spots & Creative Plan",
				content: `<h2>Photo Spots &amp; Creative Plan</h2><h3>Must-Capture Locations</h3><ol><li><strong>Lac Blanc</strong> -- Reflection of Mont Blanc at sunrise. Arrive by 5:30am. Tripod essential.</li><li><strong>Gornergrat Panorama</strong> -- 360-degree view with Matterhorn. Golden hour is best.</li><li><strong>Tre Cime from Rifugio Locatelli</strong> -- The iconic three peaks at golden hour. Drone shots here.</li><li><strong>Seceda Ridgeline</strong> -- Dramatic Dolomite spires. Best drone footage location.</li><li><strong>Lago di Braies</strong> -- Turquoise water, use ND filters for long exposure reflections.</li></ol><h3>Zine Plan (Maya)</h3><p>We are making an <strong>Alpine Explorer Zine</strong> after the trip:</p><ul><li>Format: A5 risograph, 50 copies</li><li>Print at Chamonix Print Collective</li><li>Content: best photos, trail notes, hand-drawn maps</li><li>Price: EUR 12 per copy on rCart</li></ul>`,
				content_plain: "Photography and creative plan including must-capture locations, drone shot list, zine production details, and video plan.",
				content_format: 'html',
				type: "NOTE", tags: ["photography", "creative", "planning"], is_pinned: false,
				created_at: new Date(now - 5 * day).toISOString(), updated_at: new Date(now - 4 * hour).toISOString(),
			},
		];

		const packingNotes: Note[] = [
			{
				id: "demo-note-7", title: "Packing Checklist",
				content: `<h2>Packing Checklist</h2><h3>Footwear</h3><ul data-type="taskList"><li data-type="taskItem" data-checked="true">Hiking boots (broken in!)</li><li data-type="taskItem" data-checked="false">Camp sandals / flip-flops</li><li data-type="taskItem" data-checked="false">Extra laces</li></ul><h3>Clothing</h3><ul data-type="taskList"><li data-type="taskItem" data-checked="true">Rain jacket (Gore-Tex)</li><li data-type="taskItem" data-checked="false">Down jacket for hut nights</li><li data-type="taskItem" data-checked="false">3x wool base layers</li><li data-type="taskItem" data-checked="false">2x hiking pants</li><li data-type="taskItem" data-checked="false">Sun hat + warm beanie</li></ul><h3>Gear</h3><ul data-type="taskList"><li data-type="taskItem" data-checked="true">Headlamp + spare batteries</li><li data-type="taskItem" data-checked="false">Trekking poles (collapsible)</li><li data-type="taskItem" data-checked="true">First aid kit</li><li data-type="taskItem" data-checked="false">Sunscreen SPF 50</li><li data-type="taskItem" data-checked="false">Water filter (Sawyer Squeeze)</li></ul>`,
				content_plain: "Complete packing checklist organized by category: footwear, clothing, gear, electronics, documents, and food.",
				content_format: 'html',
				type: "NOTE", tags: ["packing", "gear", "checklist"], is_pinned: true,
				created_at: new Date(now - 6 * day).toISOString(), updated_at: new Date(now - hour).toISOString(),
			},
			{
				id: "demo-note-8", title: "Food & Cooking Plan",
				content: `<h2>Food &amp; Cooking Plan</h2><h3>Hut Meals (Half-Board)</h3><p>Lac Blanc and Locatelli include dinner + breakfast. Budget EUR 0 for those nights.</p><h3>Self-Catering Days</h3><p>We have a kitchen in the Chamonix Airbnb and Zermatt apartment.</p><h3>Trail Lunches</h3><p>Pack these the night before each hike:</p><ul><li>Sandwiches (baguette + cheese + ham)</li><li>Energy bars (2 per person)</li><li>Nuts and dried fruit</li><li>Chocolate (the altitude calls for it)</li><li>1.5L water minimum</li></ul>`,
				content_plain: "Food and cooking plan covering hut meals, self-catering, trail lunches, special restaurant meals, and dietary notes.",
				content_format: 'html',
				type: "NOTE", tags: ["food", "planning", "budget"], is_pinned: false,
				created_at: new Date(now - 4 * day).toISOString(), updated_at: new Date(now - 8 * hour).toISOString(),
			},
			{
				id: "demo-note-9", title: "Transport & Logistics",
				content: `<h2>Transport &amp; Logistics</h2><h3>Getting There</h3><ul><li><strong>Jul 6</strong>: Fly to Geneva (everyone arrives by 14:00)</li><li>Geneva to Chamonix shuttle: EUR 186 for 4 pax</li></ul><h3>Between Destinations</h3><ul><li><strong>Jul 10</strong>: Chamonix to Zermatt -- Train via Martigny (~3.5h, scenic)</li><li><strong>Jul 14</strong>: Zermatt to Dolomites -- Bernina Express (6 hours but spectacular)</li></ul><h3>Local Transport</h3><ul><li><strong>Chamonix</strong>: Free local bus with guest card</li><li><strong>Zermatt</strong>: Car-free! Electric taxis + Gornergrat railway</li><li><strong>Dolomites</strong>: Need rental car or local bus (limited schedule)</li></ul>`,
				content_plain: "Transport and logistics plan covering flights, inter-city transfers, local transport options, return journey, and timetables.",
				content_format: 'html',
				type: "NOTE", tags: ["transport", "logistics"], is_pinned: false,
				created_at: new Date(now - 9 * day).toISOString(), updated_at: new Date(now - 5 * hour).toISOString(),
			},
		];

		const itineraryNotes: Note[] = [
			{
				id: "demo-note-10", title: "Full Itinerary -- Alpine Explorer 2026",
				content: `<h2>Full Itinerary -- Alpine Explorer 2026</h2><p><strong>Jul 6-20 | France, Switzerland, Italy</strong></p><h3>Week 1: Chamonix, France (Jul 6-10)</h3><ul><li>Jul 6: Fly Geneva, shuttle to Chamonix</li><li>Jul 7: Acclimatization hike -- Lac Blanc</li><li>Jul 8: Via Ferrata -- Aiguille du Midi</li><li>Jul 9: Rest day / Chamonix town</li><li>Jul 10: Train to Zermatt</li></ul><h3>Week 2: Zermatt, Switzerland (Jul 10-14)</h3><ul><li>Jul 10: Arrive Zermatt, settle in</li><li>Jul 11: Gornergrat sunrise hike</li><li>Jul 12: Matterhorn base camp trek</li><li>Jul 13: Paragliding over Zermatt</li><li>Jul 14: Transfer to Dolomites</li></ul><h3>Week 3: Dolomites, Italy (Jul 14-20)</h3><ul><li>Jul 14: Arrive Val Gardena</li><li>Jul 15: Tre Cime di Lavaredo loop</li><li>Jul 16: Lago di Braies kayaking</li><li>Jul 17: Seceda ridgeline hike</li><li>Jul 18: Cooking class in Bolzano</li><li>Jul 19: Free day -- shopping &amp; packing</li><li>Jul 20: Fly home from Innsbruck</li></ul>`,
				content_plain: "Complete day-by-day itinerary for the Alpine Explorer 2026 trip covering three weeks across Chamonix, Zermatt, and the Dolomites.",
				content_format: 'html',
				type: "NOTE", tags: ["itinerary", "planning"], is_pinned: true,
				created_at: new Date(now - 15 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(),
			},
			{
				id: "demo-note-11", title: "Mountain Hut Reservations",
				content: `<h2>Mountain Hut Reservations</h2><h3>Confirmed</h3><ul><li><strong>Refuge du Lac Blanc</strong> (Jul 7) -- 4 beds, half-board, conf #LB2026-234</li><li><strong>Rifugio Locatelli</strong> (Jul 15) -- 4 beds, half-board, conf #TRE2026-089</li></ul><h3>Waitlisted</h3><ul><li><strong>Hornlihutte</strong> (Matterhorn base, Jul 12) -- will know by Jul 1</li></ul><h3>Hut Etiquette Reminders</h3><ol><li>Arrive before 17:00 if possible</li><li>Remove boots at entrance (bring hut shoes or thick socks)</li><li>Lights out by 22:00</li><li>Pack out all trash</li><li>Tip is appreciated but not required</li></ol>`,
				content_plain: "Mountain hut reservations with confirmation numbers, check-in details, and hut etiquette reminders.",
				content_format: 'html',
				type: "NOTE", tags: ["accommodation", "hiking"], is_pinned: false,
				created_at: new Date(now - 11 * day).toISOString(), updated_at: new Date(now - day).toISOString(),
			},
			{
				id: "demo-note-12", title: "Group Decisions & Votes",
				content: `<h2>Group Decisions &amp; Votes</h2><h3>Decided</h3><ul><li><strong>Camera Gear</strong>: DJI Mini 4 Pro (Liam's decision matrix: 8.5/10)</li><li><strong>First Night Dinner in Zermatt</strong>: Fondue at Chez Vrony (won 5-4 over pizza)</li><li><strong>Day 5 Activity</strong>: Via Ferrata at Aiguille du Midi (won 7-3 over kayaking)</li></ul><h3>Active Votes (in rVote)</h3><ul><li><strong>Zermatt to Dolomites transfer</strong>: Train vs rental car -- Train leading 3-2</li></ul><h3>Pending Decisions</h3><ul><li>Val Gardena accommodation (agriturismo vs apartment)</li><li>Whether to rent the Starlink Mini (EUR 200, needs funding)</li><li>Trip zine print run size (50 vs 100 copies)</li></ul>`,
				content_plain: "Summary of group decisions made and active votes. Covers camera gear, dining, activities, and pending decisions.",
				content_format: 'html',
				type: "NOTE", tags: ["decisions", "planning"], is_pinned: false,
				created_at: new Date(now - 3 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(),
			},
		];

		this.demoNotebooks = [
			{
				id: "demo-nb-1", title: "Alpine Explorer Planning", description: "Shared knowledge base for our July 2026 trip across France, Switzerland, and Italy",
				cover_color: "#f59e0b", note_count: "6", updated_at: new Date(now - hour).toISOString(),
				notes: tripPlanningNotes,
			} as any,
			{
				id: "demo-nb-2", title: "Packing & Logistics", description: "Checklists, food plans, and transport details",
				cover_color: "#22c55e", note_count: "3", updated_at: new Date(now - hour).toISOString(),
				notes: packingNotes,
			} as any,
			{
				id: "demo-nb-3", title: "Itinerary & Decisions", description: "Day-by-day schedule, hut reservations, and group votes",
				cover_color: "#6366f1", note_count: "3", updated_at: new Date(now - 2 * hour).toISOString(),
				notes: itineraryNotes,
			} as any,
		];

		this.notebooks = this.demoNotebooks.map(({ notes, ...nb }) => nb as Notebook);
		this.loading = false;
		this.render();
	}

	private demoSearchNotes(query: string) {
		if (!query.trim()) {
			this.searchResults = [];
			this.render();
			return;
		}
		const q = query.toLowerCase();
		const all = this.demoNotebooks.flatMap(nb => nb.notes);
		this.searchResults = all.filter(n =>
			n.title.toLowerCase().includes(q) ||
			n.content_plain.toLowerCase().includes(q) ||
			(n.tags && n.tags.some(t => t.toLowerCase().includes(q)))
		);
		this.render();
	}

	private demoLoadNotebook(id: string) {
		const nb = this.demoNotebooks.find(n => n.id === id);
		if (nb) {
			this.selectedNotebook = { ...nb };
		} else {
			this.error = "Notebook not found";
		}
		this.loading = false;
		this.render();
	}

	private demoLoadNote(id: string) {
		const allNotes = this.demoNotebooks.flatMap(nb => nb.notes);
		this.selectedNote = allNotes.find(n => n.id === id) || null;
		if (this.selectedNote) {
			this.view = "note";
			this.renderNav();
			this.renderMeta();
			this.mountEditor(this.selectedNote);
		}
	}

	private demoCreateNotebook() {
		const title = prompt("Notebook name:");
		if (!title?.trim()) return;
		const now = Date.now();
		const nb = {
			id: `demo-nb-${now}`, title, description: "",
			cover_color: "#8b5cf6", note_count: "0",
			updated_at: new Date(now).toISOString(), notes: [] as Note[],
		} as any;
		this.demoNotebooks.push(nb);
		this.notebooks = this.demoNotebooks.map(({ notes, ...rest }) => rest as Notebook);
		this.render();
	}

	private demoCreateNote() {
		if (!this.selectedNotebook) return;
		const now = Date.now();
		const noteId = `demo-note-${now}`;
		const newNote: Note = {
			id: noteId, title: "Untitled Note", content: "", content_plain: "",
			content_format: 'tiptap-json',
			type: "NOTE", tags: null, is_pinned: false,
			created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
		};
		const demoNb = this.demoNotebooks.find(n => n.id === this.selectedNotebook!.id);
		if (demoNb) {
			demoNb.notes.push(newNote);
			demoNb.note_count = String(demoNb.notes.length);
		}
		this.selectedNotebook.notes.push(newNote);
		this.selectedNotebook.note_count = String(this.selectedNotebook.notes.length);
		this.selectedNote = newNote;
		this.view = "note";
		this.renderNav();
		this.renderMeta();
		this.mountEditor(newNote);
	}

	disconnectedCallback() {
		this.destroyEditor();
		this.disconnectSync();
	}

	// ── WebSocket Sync ──

	private connectSync() {
		const proto = location.protocol === "https:" ? "wss:" : "ws:";
		const wsUrl = `${proto}//${location.host}/ws/${this.space}`;
		this.ws = new WebSocket(wsUrl);

		this.ws.onopen = () => {
			this.syncConnected = true;
			this.pingInterval = setInterval(() => {
				if (this.ws?.readyState === WebSocket.OPEN) {
					this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
				}
			}, 30000);
			if (this.subscribedDocId && this.doc) {
				this.subscribeNotebook(this.subscribedDocId.split(":").pop()!);
			}
		};

		this.ws.onmessage = (e) => {
			try {
				const msg = JSON.parse(e.data);
				if (msg.type === "sync" && msg.docId === this.subscribedDocId) {
					this.handleSyncMessage(new Uint8Array(msg.data));
				}
			} catch {
				// ignore parse errors
			}
		};

		this.ws.onclose = () => {
			this.syncConnected = false;
			if (this.pingInterval) clearInterval(this.pingInterval);
			setTimeout(() => {
				if (this.isConnected) this.connectSync();
			}, 3000);
		};

		this.ws.onerror = () => {};
	}

	private disconnectSync() {
		if (this.pingInterval) clearInterval(this.pingInterval);
		if (this.ws) {
			this.ws.onclose = null;
			this.ws.close();
			this.ws = null;
		}
		this.syncConnected = false;
	}

	private handleSyncMessage(syncMsg: Uint8Array) {
		if (!this.doc) return;

		const [newDoc, newSyncState] = Automerge.receiveSyncMessage(
			this.doc, this.syncState, syncMsg
		);
		this.doc = newDoc;
		this.syncState = newSyncState;

		const [nextState, reply] = Automerge.generateSyncMessage(this.doc, this.syncState);
		this.syncState = nextState;
		if (reply && this.ws?.readyState === WebSocket.OPEN) {
			this.ws.send(JSON.stringify({
				type: "sync", docId: this.subscribedDocId,
				data: Array.from(reply),
			}));
		}

		this.renderFromDoc();
	}

	private subscribeNotebook(notebookId: string) {
		this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`;
		this.doc = Automerge.init<NotebookDoc>();
		this.syncState = Automerge.initSyncState();

		if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;

		this.ws.send(JSON.stringify({ type: "subscribe", docIds: [this.subscribedDocId] }));

		const [s, m] = Automerge.generateSyncMessage(this.doc, this.syncState);
		this.syncState = s;
		if (m) {
			this.ws.send(JSON.stringify({
				type: "sync", docId: this.subscribedDocId,
				data: Array.from(m),
			}));
		}
	}

	private unsubscribeNotebook() {
		if (this.subscribedDocId && this.ws?.readyState === WebSocket.OPEN) {
			this.ws.send(JSON.stringify({ type: "unsubscribe", docIds: [this.subscribedDocId] }));
		}
		this.subscribedDocId = null;
		this.doc = null;
		this.syncState = Automerge.initSyncState();
	}

	/** Extract notebook + notes from Automerge doc into component state */
	private renderFromDoc() {
		if (!this.doc) return;

		const nb = this.doc.notebook;
		const items = this.doc.items;

		if (!nb) return;

		// Build notebook data from doc
		const notes: Note[] = [];
		if (items) {
			for (const [, item] of Object.entries(items)) {
				notes.push({
					id: item.id,
					title: item.title || "Untitled",
					content: item.content || "",
					content_plain: item.contentPlain || "",
					content_format: (item.contentFormat as Note['content_format']) || undefined,
					type: item.type || "NOTE",
					tags: item.tags?.length ? Array.from(item.tags) : null,
					is_pinned: item.isPinned || false,
					created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
					updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
				});
			}
		}

		notes.sort((a, b) => {
			if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1;
			return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
		});

		this.selectedNotebook = {
			id: nb.id,
			title: nb.title,
			description: nb.description || "",
			cover_color: nb.coverColor || "#3b82f6",
			note_count: String(notes.length),
			updated_at: nb.updatedAt ? new Date(nb.updatedAt).toISOString() : new Date().toISOString(),
			notes,
		};

		// If viewing a note and editor is mounted, update editor content from remote
		if (this.view === "note" && this.selectedNote && this.editor && this.editorNoteId === this.selectedNote.id) {
			const noteItem = items?.[this.selectedNote.id];
			if (noteItem) {
				this.selectedNote = {
					id: noteItem.id,
					title: noteItem.title || "Untitled",
					content: noteItem.content || "",
					content_plain: noteItem.contentPlain || "",
					content_format: (noteItem.contentFormat as Note['content_format']) || undefined,
					type: noteItem.type || "NOTE",
					tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null,
					is_pinned: noteItem.isPinned || false,
					created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
					updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
				};

				// Update editor content if different (remote change)
				const remoteContent = noteItem.content || "";
				const currentContent = noteItem.contentFormat === 'tiptap-json'
					? JSON.stringify(this.editor.getJSON())
					: this.editor.getHTML();

				if (remoteContent !== currentContent) {
					this.isRemoteUpdate = true;
					try {
						if (noteItem.contentFormat === 'tiptap-json') {
							try {
								this.editor.commands.setContent(JSON.parse(remoteContent), { emitUpdate: false });
							} catch {
								this.editor.commands.setContent(remoteContent, { emitUpdate: false });
							}
						} else {
							this.editor.commands.setContent(remoteContent, { emitUpdate: false });
						}
					} finally {
						this.isRemoteUpdate = false;
					}
				}

				// Update title input if it exists
				const titleInput = this.shadow.querySelector('#note-title-input') as HTMLInputElement;
				if (titleInput && document.activeElement !== titleInput && titleInput !== this.shadow.activeElement) {
					titleInput.value = noteItem.title || "Untitled";
				}

				// Only update nav/meta, skip contentZone
				this.renderNav();
				this.renderMeta();
				this.loading = false;
				return;
			}
		}

		// If viewing a specific note without editor mounted, update selectedNote
		if (this.view === "note" && this.selectedNote) {
			const noteItem = items?.[this.selectedNote.id];
			if (noteItem) {
				this.selectedNote = {
					id: noteItem.id,
					title: noteItem.title || "Untitled",
					content: noteItem.content || "",
					content_plain: noteItem.contentPlain || "",
					content_format: (noteItem.contentFormat as Note['content_format']) || undefined,
					type: noteItem.type || "NOTE",
					tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null,
					is_pinned: noteItem.isPinned || false,
					created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
					updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
				};
			}
		}

		this.loading = false;
		this.render();
	}

	// ── Automerge mutations ──

	private createNoteViaSync() {
		if (!this.doc || !this.selectedNotebook) return;

		const noteId = crypto.randomUUID();
		const now = Date.now();

		this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => {
			if (!d.items) (d as any).items = {};
			d.items[noteId] = {
				id: noteId,
				notebookId: this.selectedNotebook!.id,
				title: "Untitled Note",
				content: "",
				contentPlain: "",
				contentFormat: "tiptap-json",
				type: "NOTE",
				tags: [],
				isPinned: false,
				sortOrder: 0,
				createdAt: now,
				updatedAt: now,
			};
		});

		this.sendSyncAfterChange();
		this.renderFromDoc();

		// Open the new note
		this.selectedNote = {
			id: noteId, title: "Untitled Note", content: "", content_plain: "",
			content_format: 'tiptap-json',
			type: "NOTE", tags: null, is_pinned: false,
			created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
		};
		this.view = "note";
		this.renderNav();
		this.renderMeta();
		this.mountEditor(this.selectedNote);
	}

	private updateNoteField(noteId: string, field: string, value: string) {
		if (!this.doc || !this.doc.items?.[noteId]) return;

		this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => {
			(d.items[noteId] as any)[field] = value;
			d.items[noteId].updatedAt = Date.now();
		});

		this.sendSyncAfterChange();
	}

	private sendSyncAfterChange() {
		if (!this.doc || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;

		const [newState, msg] = Automerge.generateSyncMessage(this.doc, this.syncState);
		this.syncState = newState;
		if (msg) {
			this.ws.send(JSON.stringify({
				type: "sync", docId: this.subscribedDocId,
				data: Array.from(msg),
			}));
		}
	}

	// ── REST (notebook list + search) ──

	private getApiBase(): string {
		const path = window.location.pathname;
		const match = path.match(/^(\/[^/]+)?\/rnotes/);
		return match ? match[0] : "";
	}

	private async loadNotebooks() {
		this.loading = true;
		this.render();
		try {
			const base = this.getApiBase();
			const res = await fetch(`${base}/api/notebooks`);
			const data = await res.json();
			this.notebooks = data.notebooks || [];
		} catch {
			this.error = "Failed to load notebooks";
		}
		this.loading = false;
		this.render();
	}

	private async loadNotebook(id: string) {
		this.loading = true;
		this.render();

		this.unsubscribeNotebook();
		this.subscribeNotebook(id);

		setTimeout(() => {
			if (this.loading && this.view === "notebook") {
				this.loadNotebookREST(id);
			}
		}, 5000);
	}

	private async loadNotebookREST(id: string) {
		try {
			const base = this.getApiBase();
			const res = await fetch(`${base}/api/notebooks/${id}`);
			this.selectedNotebook = await res.json();
		} catch {
			this.error = "Failed to load notebook";
		}
		this.loading = false;
		this.render();
	}

	private loadNote(id: string) {
		// Note is already in the Automerge doc
		if (this.doc?.items?.[id]) {
			const item = this.doc.items[id];
			this.selectedNote = {
				id: item.id,
				title: item.title || "Untitled",
				content: item.content || "",
				content_plain: item.contentPlain || "",
				content_format: (item.contentFormat as Note['content_format']) || undefined,
				type: item.type || "NOTE",
				tags: item.tags?.length ? Array.from(item.tags) : null,
				is_pinned: item.isPinned || false,
				created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
				updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
			};
		} else if (this.selectedNotebook?.notes) {
			this.selectedNote = this.selectedNotebook.notes.find(n => n.id === id) || null;
		}

		if (this.selectedNote) {
			this.renderNav();
			this.renderMeta();
			this.mountEditor(this.selectedNote);
		}
	}

	private async searchNotes(query: string) {
		if (!query.trim()) {
			this.searchResults = [];
			this.render();
			return;
		}
		try {
			const base = this.getApiBase();
			const res = await fetch(`${base}/api/notes?q=${encodeURIComponent(query)}`);
			const data = await res.json();
			this.searchResults = data.notes || [];
		} catch {
			this.searchResults = [];
		}
		this.render();
	}

	private async createNotebook() {
		const title = prompt("Notebook name:");
		if (!title?.trim()) return;
		try {
			const base = this.getApiBase();
			await fetch(`${base}/api/notebooks`, {
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify({ title }),
			});
			await this.loadNotebooks();
		} catch {
			this.error = "Failed to create notebook";
			this.render();
		}
	}

	// ── Tiptap Editor ──

	private mountEditor(note: Note) {
		this.destroyEditor();

		// Build content zone
		const isDemo = this.space === "demo";
		const isAutomerge = !!(this.doc?.items?.[note.id]);
		const isEditable = isAutomerge || isDemo;

		this.contentZone.innerHTML = `
			<div class="editor-wrapper">
				<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
				${isEditable ? this.renderToolbar() : ''}
				<div class="tiptap-container" id="tiptap-container"></div>
			</div>
		`;

		const container = this.shadow.getElementById('tiptap-container');
		if (!container) return;

		// Determine content to load
		let content: any = '';
		if (note.content) {
			if (note.content_format === 'tiptap-json') {
				try {
					content = JSON.parse(note.content);
				} catch {
					content = note.content;
				}
			} else {
				// HTML content (legacy or explicit)
				content = note.content;
			}
		}

		const slashPlugin = createSlashCommandPlugin(
			null as any, // Will be set after editor creation
			this.shadow
		);

		this.editor = new Editor({
			element: container,
			editable: isEditable,
			extensions: [
				StarterKit.configure({
					codeBlock: false,
					heading: { levels: [1, 2, 3, 4] },
				}),
				Link.configure({ openOnClick: false }),
				Image,
				TaskList,
				TaskItem.configure({ nested: true }),
				Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
				CodeBlockLowlight.configure({ lowlight }),
				Typography,
				Underline,
			],
			content,
			onUpdate: ({ editor }) => {
				if (this.isRemoteUpdate) return;
				if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer);
				this.editorUpdateTimer = setTimeout(() => {
					const json = JSON.stringify(editor.getJSON());
					const plain = editor.getText();
					const noteId = this.editorNoteId;
					if (!noteId) return;

					if (isDemo) {
						this.demoUpdateNoteField(noteId, "content", json);
						this.demoUpdateNoteField(noteId, "content_plain", plain);
						this.demoUpdateNoteField(noteId, "content_format", 'tiptap-json');
					} else {
						this.updateNoteField(noteId, "content", json);
						this.updateNoteField(noteId, "contentPlain", plain);
						this.updateNoteField(noteId, "contentFormat", 'tiptap-json');
					}
				}, 800);
			},
			onSelectionUpdate: () => {
				this.updateToolbarState();
			},
		});

		// Now register the slash command plugin with the actual editor
		this.editor.registerPlugin(
			createSlashCommandPlugin(this.editor, this.shadow)
		);

		this.editorNoteId = note.id;

		// Listen for slash command image insert (custom event from slash-command.ts)
		container.addEventListener('slash-insert-image', () => {
			if (!this.editor) return;
			const { from } = this.editor.view.state.selection;
			const coords = this.editor.view.coordsAtPos(from);
			const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
			this.showUrlPopover(rect, 'Enter image URL...').then(url => {
				if (url) this.editor!.chain().focus().setImage({ src: url }).run();
			});
		});

		// Wire up title input
		const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement;
		if (titleInput) {
			let titleTimeout: any;
			titleInput.addEventListener("input", () => {
				clearTimeout(titleTimeout);
				titleTimeout = setTimeout(() => {
					if (isDemo) {
						this.demoUpdateNoteField(note.id, "title", titleInput.value);
					} else {
						this.updateNoteField(note.id, "title", titleInput.value);
					}
				}, 500);
			});
		}

		// Wire up toolbar
		this.attachToolbarListeners();
	}

	private destroyEditor() {
		if (this.editorUpdateTimer) {
			clearTimeout(this.editorUpdateTimer);
			this.editorUpdateTimer = null;
		}
		if (this.editor) {
			this.editor.destroy();
			this.editor = null;
		}
		this.editorNoteId = null;
	}

	private renderToolbar(): string {
		const btn = (cmd: string, title: string) =>
			`<button class="toolbar-btn" data-cmd="${cmd}" title="${title}">${ICONS[cmd]}</button>`;
		return `
		<div class="editor-toolbar" id="editor-toolbar">
			<div class="toolbar-group">
				${btn('bold', 'Bold (Ctrl+B)')}
				${btn('italic', 'Italic (Ctrl+I)')}
				${btn('underline', 'Underline (Ctrl+U)')}
				${btn('strike', 'Strikethrough')}
				${btn('code', 'Inline Code')}
			</div>
			<div class="toolbar-sep"></div>
			<div class="toolbar-group">
				<select class="toolbar-select" data-cmd="heading" title="Block type">
					<option value="paragraph">Paragraph</option>
					<option value="1">Heading 1</option>
					<option value="2">Heading 2</option>
					<option value="3">Heading 3</option>
					<option value="4">Heading 4</option>
				</select>
			</div>
			<div class="toolbar-sep"></div>
			<div class="toolbar-group">
				${btn('bulletList', 'Bullet List')}
				${btn('orderedList', 'Numbered List')}
				${btn('taskList', 'Task List')}
			</div>
			<div class="toolbar-sep"></div>
			<div class="toolbar-group">
				${btn('blockquote', 'Blockquote')}
				${btn('codeBlock', 'Code Block')}
				${btn('horizontalRule', 'Divider')}
			</div>
			<div class="toolbar-sep"></div>
			<div class="toolbar-group">
				${btn('link', 'Insert Link')}
				${btn('image', 'Insert Image')}
			</div>
			<div class="toolbar-sep"></div>
			<div class="toolbar-group">
				${btn('undo', 'Undo (Ctrl+Z)')}
				${btn('redo', 'Redo (Ctrl+Y)')}
			</div>
		</div>`;
	}

	private showUrlPopover(anchorRect: DOMRect, placeholder: string): Promise<string | null> {
		return new Promise((resolve) => {
			this.shadow.querySelector('.url-popover')?.remove();

			const popover = document.createElement('div');
			popover.className = 'url-popover';

			const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect();
			popover.style.left = `${anchorRect.left - hostRect.left}px`;
			popover.style.top = `${anchorRect.bottom - hostRect.top + 4}px`;

			const input = document.createElement('input');
			input.type = 'url';
			input.placeholder = placeholder;
			input.className = 'url-popover__input';

			const insertBtn = document.createElement('button');
			insertBtn.textContent = 'Insert';
			insertBtn.className = 'url-popover__btn url-popover__btn--insert';

			const cancelBtn = document.createElement('button');
			cancelBtn.textContent = 'Cancel';
			cancelBtn.className = 'url-popover__btn url-popover__btn--cancel';

			const btnRow = document.createElement('div');
			btnRow.className = 'url-popover__actions';
			btnRow.append(cancelBtn, insertBtn);

			popover.append(input, btnRow);
			this.shadow.appendChild(popover);
			input.focus();

			const cleanup = (value: string | null) => {
				popover.remove();
				resolve(value);
			};

			insertBtn.addEventListener('click', () => {
				const val = input.value.trim();
				cleanup(val || null);
			});

			cancelBtn.addEventListener('click', () => cleanup(null));

			input.addEventListener('keydown', (e) => {
				if (e.key === 'Enter') {
					e.preventDefault();
					const val = input.value.trim();
					cleanup(val || null);
				}
				if (e.key === 'Escape') {
					e.preventDefault();
					cleanup(null);
				}
			});
		});
	}

	private attachToolbarListeners() {
		const toolbar = this.shadow.getElementById('editor-toolbar');
		if (!toolbar || !this.editor) return;

		// Button clicks via event delegation
		toolbar.addEventListener('click', (e) => {
			const btn = (e.target as HTMLElement).closest('[data-cmd]') as HTMLElement;
			if (!btn || btn.tagName === 'SELECT') return;
			e.preventDefault();
			const cmd = btn.dataset.cmd;
			if (!this.editor) return;

			switch (cmd) {
				case 'bold': this.editor.chain().focus().toggleBold().run(); break;
				case 'italic': this.editor.chain().focus().toggleItalic().run(); break;
				case 'underline': this.editor.chain().focus().toggleUnderline().run(); break;
				case 'strike': this.editor.chain().focus().toggleStrike().run(); break;
				case 'code': this.editor.chain().focus().toggleCode().run(); break;
				case 'bulletList': this.editor.chain().focus().toggleBulletList().run(); break;
				case 'orderedList': this.editor.chain().focus().toggleOrderedList().run(); break;
				case 'taskList': this.editor.chain().focus().toggleTaskList().run(); break;
				case 'blockquote': this.editor.chain().focus().toggleBlockquote().run(); break;
				case 'codeBlock': this.editor.chain().focus().toggleCodeBlock().run(); break;
				case 'horizontalRule': this.editor.chain().focus().setHorizontalRule().run(); break;
				case 'link': {
					const rect = btn.getBoundingClientRect();
					this.showUrlPopover(rect, 'Enter link URL...').then(url => {
						if (url) this.editor!.chain().focus().setLink({ href: url }).run();
						else this.editor!.chain().focus().run();
					});
					break;
				}
				case 'image': {
					const rect = btn.getBoundingClientRect();
					this.showUrlPopover(rect, 'Enter image URL...').then(url => {
						if (url) this.editor!.chain().focus().setImage({ src: url }).run();
						else this.editor!.chain().focus().run();
					});
					break;
				}
				case 'undo': this.editor.chain().focus().undo().run(); break;
				case 'redo': this.editor.chain().focus().redo().run(); break;
			}
		});

		// Heading select
		const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement;
		if (headingSelect) {
			headingSelect.addEventListener('change', () => {
				if (!this.editor) return;
				const val = headingSelect.value;
				if (val === 'paragraph') {
					this.editor.chain().focus().setParagraph().run();
				} else {
					this.editor.chain().focus().setHeading({ level: parseInt(val) as 1 | 2 | 3 | 4 }).run();
				}
			});
		}
	}

	private updateToolbarState() {
		if (!this.editor) return;
		const toolbar = this.shadow.getElementById('editor-toolbar');
		if (!toolbar) return;

		// Toggle active class on buttons
		toolbar.querySelectorAll('.toolbar-btn[data-cmd]').forEach((btn) => {
			const cmd = (btn as HTMLElement).dataset.cmd!;
			let isActive = false;
			switch (cmd) {
				case 'bold': isActive = this.editor!.isActive('bold'); break;
				case 'italic': isActive = this.editor!.isActive('italic'); break;
				case 'underline': isActive = this.editor!.isActive('underline'); break;
				case 'strike': isActive = this.editor!.isActive('strike'); break;
				case 'code': isActive = this.editor!.isActive('code'); break;
				case 'bulletList': isActive = this.editor!.isActive('bulletList'); break;
				case 'orderedList': isActive = this.editor!.isActive('orderedList'); break;
				case 'taskList': isActive = this.editor!.isActive('taskList'); break;
				case 'blockquote': isActive = this.editor!.isActive('blockquote'); break;
				case 'codeBlock': isActive = this.editor!.isActive('codeBlock'); break;
			}
			btn.classList.toggle('active', isActive);
		});

		// Update heading select
		const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement;
		if (headingSelect) {
			if (this.editor.isActive('heading', { level: 1 })) headingSelect.value = '1';
			else if (this.editor.isActive('heading', { level: 2 })) headingSelect.value = '2';
			else if (this.editor.isActive('heading', { level: 3 })) headingSelect.value = '3';
			else if (this.editor.isActive('heading', { level: 4 })) headingSelect.value = '4';
			else headingSelect.value = 'paragraph';
		}
	}

	// ── Helpers ──

	private getNoteIcon(type: string): string {
		switch (type) {
			case "NOTE": return "\u{1F4DD}";
			case "CODE": return "\u{1F4BB}";
			case "BOOKMARK": return "\u{1F517}";
			case "IMAGE": return "\u{1F5BC}";
			case "AUDIO": return "\u{1F3A4}";
			case "FILE": return "\u{1F4CE}";
			case "CLIP": return "\u2702\uFE0F";
			default: return "\u{1F4C4}";
		}
	}

	private formatDate(dateStr: string): string {
		const d = new Date(dateStr);
		const now = new Date();
		const diffMs = now.getTime() - d.getTime();
		const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
		if (diffDays === 0) return "Today";
		if (diffDays === 1) return "Yesterday";
		if (diffDays < 7) return `${diffDays}d ago`;
		return d.toLocaleDateString();
	}

	// ── Rendering ──

	private render() {
		this.renderNav();
		if (this.view === 'note' && this.editor && this.editorNoteId) {
			// Editor already mounted — don't touch contentZone
		} else {
			this.renderContent();
		}
		this.renderMeta();
		this.attachListeners();
	}

	private renderNav() {
		const isDemo = this.space === "demo";

		if (this.view === "note" && this.selectedNote) {
			// Nav is handled by mountEditor's content, or we just show back button
			this.navZone.innerHTML = `
				<div class="rapp-nav">
					<button class="rapp-nav__back" data-back="${this.selectedNotebook ? "notebook" : "notebooks"}">
						\u2190 ${this.selectedNotebook ? this.esc(this.selectedNotebook.title) : "Notebooks"}
					</button>
					<span style="flex:1"></span>
				</div>`;
			// Re-attach back listener
			this.navZone.querySelectorAll('[data-back]').forEach((el) => {
				el.addEventListener('click', (e) => {
					e.stopPropagation();
					const target = (el as HTMLElement).dataset.back;
					this.destroyEditor();
					if (target === 'notebooks') {
						this.view = 'notebooks';
						if (!isDemo) this.unsubscribeNotebook();
						this.selectedNotebook = null;
						this.selectedNote = null;
						this.render();
					} else if (target === 'notebook') {
						this.view = 'notebook';
						this.selectedNote = null;
						this.render();
					}
				});
			});
			return;
		}

		if (this.view === "notebook" && this.selectedNotebook) {
			const nb = this.selectedNotebook;
			const syncBadge = this.subscribedDocId
				? `<span class="sync-badge ${this.syncConnected ? "connected" : "disconnected"}" title="${this.syncConnected ? "Live sync" : "Reconnecting..."}"></span>`
				: "";
			this.navZone.innerHTML = `
				<div class="rapp-nav">
					<button class="rapp-nav__back" data-back="notebooks">\u2190 Notebooks</button>
					<span class="rapp-nav__title">${this.esc(nb.title)}${syncBadge}</span>
					<button class="rapp-nav__btn rapp-nav__btn--secondary" id="btn-export-notebook" title="Export this notebook">
						<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 10V2M5 5l3-3 3 3"/><path d="M2 12v2h12v-2"/></svg>
						Export
					</button>
					<button class="rapp-nav__btn" id="create-note">+ New Note</button>
				</div>`;
			return;
		}

		// Notebooks view
		this.navZone.innerHTML = `
			<div class="rapp-nav">
				<span class="rapp-nav__title">Notebooks</span>
				<button class="rapp-nav__btn rapp-nav__btn--secondary" id="btn-import-export" title="Import / Export">
					<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v8M5 7l3 3 3-3"/><path d="M2 12v2h12v-2"/></svg>
					Import / Export
				</button>
				<button class="rapp-nav__btn" id="create-notebook">+ New Notebook</button>
			</div>
			<input class="search-bar" type="text" placeholder="Search notes..." id="search-input" value="${this.esc(this.searchQuery)}">`;
	}

	private renderContent() {
		if (this.error) {
			this.contentZone.innerHTML = `<div class="error">${this.esc(this.error)}</div>`;
			return;
		}
		if (this.loading) {
			this.contentZone.innerHTML = '<div class="loading">Loading...</div>';
			return;
		}

		if (this.view === "notebook" && this.selectedNotebook) {
			const nb = this.selectedNotebook;
			this.contentZone.innerHTML = nb.notes && nb.notes.length > 0
				? nb.notes.map((n) => this.renderNoteItem(n)).join("")
				: '<div class="empty">No notes in this notebook.</div>';
			return;
		}

		// Notebooks view
		let html = '';
		if (this.searchQuery && this.searchResults.length > 0) {
			html += `<div class="search-results-info">${this.searchResults.length} results for "${this.esc(this.searchQuery)}"</div>`;
			html += this.searchResults.map((n) => this.renderNoteItem(n)).join("");
		}
		if (!this.searchQuery) {
			html += `<div class="grid">${this.notebooks.map((nb) => `
				<div class="notebook-card" data-notebook="${nb.id}">
					<div class="notebook-card__accent" style="background:${nb.cover_color}"></div>
					<div class="notebook-card__body">
						<div class="notebook-card__title">${this.esc(nb.title)}</div>
						<div class="notebook-card__desc">${this.esc(nb.description || "")}</div>
					</div>
					<div class="notebook-card__footer">
						<span>${nb.note_count} notes</span>
						<span>${this.formatDate(nb.updated_at)}</span>
					</div>
				</div>
			`).join("")}</div>`;
			if (this.notebooks.length === 0) {
				html += '<div class="empty">No notebooks yet. Create one to get started.</div>';
			}
		}
		this.contentZone.innerHTML = html;
	}

	private renderMeta() {
		if (this.view === "note" && this.selectedNote) {
			const n = this.selectedNote;
			const isAutomerge = !!(this.doc?.items?.[n.id]);
			const isDemo = this.space === "demo";
			this.metaZone.innerHTML = `
				<div class="note-meta-bar">
					<span>Type: ${n.type}</span>
					<span>Created: ${this.formatDate(n.created_at)}</span>
					<span>Updated: ${this.formatDate(n.updated_at)}</span>
					${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}
					${isAutomerge ? '<span class="meta-live">Live</span>' : ""}
					${isDemo ? '<span class="meta-demo">Demo</span>' : ""}
				</div>`;
		} else {
			this.metaZone.innerHTML = '';
		}
	}

	private renderNoteItem(n: Note): string {
		return `
			<div class="note-item" data-note="${n.id}">
				<span class="note-item__icon">${this.getNoteIcon(n.type)}</span>
				<div class="note-item__body">
					<div class="note-item__title">${n.is_pinned ? '<span class="note-item__pin">\u{1F4CC}</span> ' : ""}${this.esc(n.title)}</div>
					<div class="note-item__preview">${this.esc(n.content_plain || "")}</div>
					<div class="note-item__meta">
						<span>${this.formatDate(n.updated_at)}</span>
						<span>${n.type}</span>
						${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}
					</div>
				</div>
			</div>
		`;
	}

	private attachListeners() {
		const isDemo = this.space === "demo";

		// Import / Export button
		this.shadow.getElementById("btn-import-export")?.addEventListener("click", () => {
			this.openImportExportDialog();
		});

		// Create notebook
		this.shadow.getElementById("create-notebook")?.addEventListener("click", () => {
			isDemo ? this.demoCreateNotebook() : this.createNotebook();
		});

		// Export notebook button (in notebook detail view)
		this.shadow.getElementById("btn-export-notebook")?.addEventListener("click", () => {
			this.openImportExportDialog('export');
		});

		// Create note
		this.shadow.getElementById("create-note")?.addEventListener("click", () => {
			isDemo ? this.demoCreateNote() : this.createNoteViaSync();
		});

		// Search
		const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement;
		let searchTimeout: any;
		searchInput?.addEventListener("input", () => {
			clearTimeout(searchTimeout);
			this.searchQuery = searchInput.value;
			searchTimeout = setTimeout(() => {
				isDemo ? this.demoSearchNotes(this.searchQuery) : this.searchNotes(this.searchQuery);
			}, 300);
		});

		// Notebook cards
		this.shadow.querySelectorAll("[data-notebook]").forEach((el) => {
			el.addEventListener("click", () => {
				const id = (el as HTMLElement).dataset.notebook!;
				this.view = "notebook";
				isDemo ? this.demoLoadNotebook(id) : this.loadNotebook(id);
			});
		});

		// Note items
		this.shadow.querySelectorAll("[data-note]").forEach((el) => {
			el.addEventListener("click", () => {
				const id = (el as HTMLElement).dataset.note!;
				this.view = "note";
				isDemo ? this.demoLoadNote(id) : this.loadNote(id);
			});
		});

		// Back buttons (for notebook view)
		this.navZone.querySelectorAll("[data-back]").forEach((el) => {
			el.addEventListener("click", (e) => {
				e.stopPropagation();
				const target = (el as HTMLElement).dataset.back;
				if (target === "notebooks") {
					this.destroyEditor();
					this.view = "notebooks";
					if (!isDemo) this.unsubscribeNotebook();
					this.selectedNotebook = null;
					this.selectedNote = null;
					this.render();
				}
				else if (target === "notebook") {
					this.destroyEditor();
					this.view = "notebook";
					this.selectedNote = null;
					this.render();
				}
			});
		});
	}

	private demoUpdateNoteField(noteId: string, field: string, value: string) {
		if (this.selectedNote && this.selectedNote.id === noteId) {
			(this.selectedNote as any)[field] = value;
			this.selectedNote.updated_at = new Date().toISOString();
		}
		for (const nb of this.demoNotebooks) {
			const note = nb.notes.find(n => n.id === noteId);
			if (note) {
				(note as any)[field] = value;
				note.updated_at = new Date().toISOString();
				break;
			}
		}
		if (this.selectedNotebook?.notes) {
			const note = this.selectedNotebook.notes.find(n => n.id === noteId);
			if (note) {
				(note as any)[field] = value;
				note.updated_at = new Date().toISOString();
			}
		}
	}

	// ── Import/Export Dialog ──

	private importExportDialog: ImportExportDialog | null = null;

	private openImportExportDialog(tab: 'import' | 'export' = 'import') {
		if (!this.importExportDialog) {
			// Dynamically import the dialog component
			import('./import-export-dialog').then(() => {
				this.importExportDialog = document.createElement('import-export-dialog') as unknown as ImportExportDialog;
				this.importExportDialog.setAttribute('space', this.space);
				this.shadow.appendChild(this.importExportDialog);

				this.importExportDialog.addEventListener('import-complete', () => {
					// Refresh notebooks list after import
					if (this.space === 'demo') {
						this.loadDemoData();
					} else {
						this.loadNotebooks();
					}
				});

				this.showDialog(tab);
			});
		} else {
			this.showDialog(tab);
		}
	}

	private showDialog(tab: 'import' | 'export') {
		if (!this.importExportDialog) return;

		// Gather notebook list for the dialog
		const notebooks = this.notebooks.map(nb => ({
			id: nb.id,
			title: nb.title,
		}));

		this.importExportDialog.open(notebooks, tab);
	}

	private esc(s: string): string {
		const d = document.createElement("div");
		d.textContent = s || "";
		return d.innerHTML;
	}

	private getStyles(): string {
		return `
			:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
			* { box-sizing: border-box; }

			/* ── Navigation ── */
			.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
			.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 13px; transition: all 0.15s; }
			.rapp-nav__back:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
			.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
			.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; transition: background 0.15s; display: flex; align-items: center; gap: 4px; }
			.rapp-nav__btn:hover { background: var(--rs-primary-hover); }
			.rapp-nav__btn--secondary { background: transparent; border: 1px solid var(--rs-border); color: var(--rs-text-secondary); font-weight: 500; }
			.rapp-nav__btn--secondary:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); background: transparent; }

			/* ── Search ── */
			.search-bar {
				width: 100%; padding: 10px 14px; border-radius: 8px;
				border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text);
				font-size: 14px; margin-bottom: 16px; transition: border-color 0.15s;
			}
			.search-bar:focus { border-color: var(--rs-primary); outline: none; }
			.search-results-info { margin-bottom: 12px; font-size: 13px; color: var(--rs-text-secondary); }

			/* ── Notebook Grid ── */
			.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
			.notebook-card {
				position: relative; overflow: hidden;
				border-radius: 12px; padding: 16px 16px 16px 20px; cursor: pointer;
				border: 1px solid var(--rs-card-border); background: var(--rs-card-bg);
				transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
				min-height: 120px; display: flex; flex-direction: column; justify-content: space-between;
			}
			.notebook-card:hover { border-color: var(--rs-border); box-shadow: var(--rs-shadow-sm); transform: translateY(-1px); }
			.notebook-card__accent { position: absolute; top: 0; left: 0; width: 4px; height: 100%; border-radius: 12px 0 0 12px; }
			.notebook-card__body { flex: 1; }
			.notebook-card__title { font-size: 15px; font-weight: 600; margin-bottom: 4px; color: var(--rs-text-primary); }
			.notebook-card__desc { font-size: 12px; color: var(--rs-text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
			.notebook-card__footer { display: flex; justify-content: space-between; font-size: 11px; color: var(--rs-text-muted); margin-top: 8px; }

			/* ── Note Items ── */
			.note-item {
				background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 10px;
				padding: 14px 16px; margin-bottom: 8px; cursor: pointer;
				transition: border-color 0.15s, box-shadow 0.15s;
				display: flex; gap: 12px; align-items: flex-start;
			}
			.note-item:hover { border-color: var(--rs-border); box-shadow: var(--rs-shadow-sm); }
			.note-item__icon {
				font-size: 18px; flex-shrink: 0; width: 32px; height: 32px;
				display: flex; align-items: center; justify-content: center;
				background: var(--rs-bg-surface-raised); border-radius: 8px;
			}
			.note-item__body { flex: 1; min-width: 0; }
			.note-item__title { font-size: 14px; font-weight: 600; color: var(--rs-text-primary); }
			.note-item__pin { color: var(--rs-warning); }
			.note-item__preview {
				font-size: 12px; color: var(--rs-text-muted); margin-top: 3px; line-height: 1.4;
				overflow: hidden; text-overflow: ellipsis;
				display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
			}
			.note-item__meta { font-size: 11px; color: var(--rs-text-muted); margin-top: 6px; display: flex; gap: 8px; align-items: center; }
			.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: var(--rs-bg-surface-raised); color: var(--rs-text-secondary); font-size: 10px; }

			/* ── Editor Title ── */
			.editable-title {
				background: transparent; border: none; border-bottom: 2px solid transparent;
				color: var(--rs-text-primary); font-family: inherit;
				font-size: 22px; font-weight: 700; width: 100%; outline: none;
				padding: 8px 0; margin-bottom: 4px; transition: border-color 0.15s;
			}
			.editable-title:focus { border-bottom-color: var(--rs-primary); }
			.editable-title::placeholder { color: var(--rs-text-muted); }

			/* ── Sync Badge ── */
			.sync-badge {
				display: inline-block; width: 8px; height: 8px; border-radius: 50%;
				margin-left: 8px; vertical-align: middle;
			}
			.sync-badge.connected { background: var(--rs-success); }
			.sync-badge.disconnected { background: var(--rs-error); }

			/* ── State Messages ── */
			.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
			.loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; }
			.error { text-align: center; color: var(--rs-error); padding: 20px; }

			/* ── Meta Bar ── */
			.note-meta-bar {
				margin-top: 12px; font-size: 12px; color: var(--rs-text-muted);
				display: flex; gap: 12px; padding: 8px 0; align-items: center;
			}
			.meta-live { color: var(--rs-success); font-weight: 500; }
			.meta-demo { color: var(--rs-warning); font-weight: 500; }

			/* ── Editor Toolbar ── */
			.editor-toolbar {
				display: flex; flex-wrap: wrap; gap: 2px; align-items: center;
				background: var(--rs-toolbar-bg); border: 1px solid var(--rs-toolbar-panel-border);
				border-radius: 8px; padding: 4px 6px; margin-bottom: 2px;
			}
			.toolbar-group { display: flex; gap: 1px; }
			.toolbar-sep { width: 1px; height: 20px; background: var(--rs-toolbar-sep); margin: 0 4px; }
			.toolbar-btn {
				display: flex; align-items: center; justify-content: center;
				width: 30px; height: 28px; border: none; border-radius: 4px;
				background: transparent; color: var(--rs-text-secondary); cursor: pointer;
				font-size: 13px; font-family: inherit; transition: all 0.15s;
			}
			.toolbar-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
			.toolbar-btn:hover { background: var(--rs-toolbar-btn-hover); color: var(--rs-toolbar-btn-text); }
			.toolbar-btn.active { background: var(--rs-primary); color: #fff; }
			.toolbar-select {
				padding: 2px 4px; border-radius: 4px; border: 1px solid var(--rs-toolbar-panel-border);
				background: var(--rs-toolbar-bg); color: var(--rs-text-secondary); font-size: 12px; cursor: pointer;
				font-family: inherit; transition: border-color 0.15s;
			}
			.toolbar-select:focus { outline: none; border-color: var(--rs-primary); }

			/* ── Tiptap Editor ── */
			.editor-wrapper {
				background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle);
				border-radius: 10px; overflow: hidden;
			}
			.editor-wrapper .editable-title { padding: 16px 20px 0; }
			.editor-wrapper .editor-toolbar { margin: 4px 8px; border-radius: 6px; }

			.tiptap-container .tiptap {
				min-height: 300px; padding: 20px 24px; outline: none;
				font-size: 15px; line-height: 1.75; color: var(--rs-text-primary);
			}
			.tiptap-container .tiptap:focus { outline: none; }

			/* ── Prose Styles ── */
			.tiptap-container .tiptap h1 {
				font-size: 1.75em; font-weight: 700; margin: 1.2em 0 0.5em; color: var(--rs-text-primary);
				padding-bottom: 0.3em; border-bottom: 1px solid var(--rs-border-subtle);
			}
			.tiptap-container .tiptap h2 { font-size: 1.35em; font-weight: 600; margin: 1em 0 0.4em; color: var(--rs-text-primary); }
			.tiptap-container .tiptap h3 { font-size: 1.1em; font-weight: 600; margin: 0.8em 0 0.3em; color: var(--rs-text-secondary); }
			.tiptap-container .tiptap h4 {
				font-size: 0.95em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
				margin: 0.7em 0 0.25em; color: var(--rs-text-muted);
			}
			.tiptap-container .tiptap p { margin: 0.5em 0; }
			.tiptap-container .tiptap blockquote {
				border-left: 3px solid var(--rs-primary); padding: 4px 0 4px 16px; margin: 0.8em 0;
				color: var(--rs-text-secondary); font-style: italic;
				background: var(--rs-bg-surface-raised); border-radius: 0 6px 6px 0;
			}
			.tiptap-container .tiptap code {
				background: var(--rs-bg-surface-raised); padding: 2px 6px; border-radius: 4px;
				font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.88em; color: var(--rs-accent);
			}
			.tiptap-container .tiptap pre {
				background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border-subtle);
				border-radius: 8px; padding: 14px 16px; margin: 1em 0; overflow-x: auto;
			}
			.tiptap-container .tiptap pre code {
				background: none; padding: 0; border-radius: 0; color: var(--rs-text-primary);
				font-size: 13px; line-height: 1.6;
			}
			.tiptap-container .tiptap ul, .tiptap-container .tiptap ol { padding-left: 24px; margin: 0.5em 0; }
			.tiptap-container .tiptap li { margin: 0.2em 0; }
			.tiptap-container .tiptap li p { margin: 0.15em 0; }
			.tiptap-container .tiptap li::marker { color: var(--rs-text-muted); }

			/* Task list */
			.tiptap-container .tiptap ul[data-type="taskList"] { list-style: none; padding-left: 4px; }
			.tiptap-container .tiptap ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 8px; }
			.tiptap-container .tiptap ul[data-type="taskList"] li label { margin-top: 3px; }
			.tiptap-container .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p {
				text-decoration: line-through; color: var(--rs-text-muted);
			}
			.tiptap-container .tiptap ul[data-type="taskList"] li label input[type="checkbox"] {
				accent-color: var(--rs-primary); width: 15px; height: 15px;
			}

			.tiptap-container .tiptap img {
				max-width: 100%; border-radius: 8px; margin: 0.75em 0;
				border: 1px solid var(--rs-border-subtle);
			}
			.tiptap-container .tiptap a {
				color: var(--rs-primary-hover); text-decoration: underline;
				text-underline-offset: 2px; text-decoration-color: rgba(99, 102, 241, 0.4);
			}
			.tiptap-container .tiptap a:hover { text-decoration-color: var(--rs-primary-hover); }
			.tiptap-container .tiptap hr { border: none; border-top: 1px solid var(--rs-border); margin: 1.5em 0; }
			.tiptap-container .tiptap strong { color: var(--rs-text-primary); font-weight: 600; }
			.tiptap-container .tiptap em { color: inherit; }
			.tiptap-container .tiptap s { color: var(--rs-text-muted); }
			.tiptap-container .tiptap u { text-underline-offset: 3px; }

			/* Placeholder */
			.tiptap-container .tiptap p.is-editor-empty:first-child::before {
				content: attr(data-placeholder);
				float: left; color: var(--rs-text-muted); pointer-events: none; height: 0;
				font-style: italic;
			}

			/* ── URL Popover ── */
			.url-popover {
				position: absolute; z-index: 110;
				background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
				border-radius: 10px; box-shadow: var(--rs-shadow-md);
				padding: 8px; min-width: 300px;
				animation: popover-in 0.15s ease-out;
			}
			@keyframes popover-in {
				from { opacity: 0; transform: translateY(-4px); }
				to { opacity: 1; transform: translateY(0); }
			}
			.url-popover__input {
				width: 100%; padding: 8px 10px; border-radius: 6px;
				border: 1px solid var(--rs-input-border); background: var(--rs-input-bg);
				color: var(--rs-input-text); font-size: 13px; font-family: inherit;
				outline: none; margin-bottom: 6px; transition: border-color 0.15s;
			}
			.url-popover__input:focus { border-color: var(--rs-primary); }
			.url-popover__actions { display: flex; gap: 6px; justify-content: flex-end; }
			.url-popover__btn {
				padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600;
				cursor: pointer; border: none; transition: all 0.15s;
			}
			.url-popover__btn--insert { background: var(--rs-primary); color: #fff; }
			.url-popover__btn--insert:hover { background: var(--rs-primary-hover); }
			.url-popover__btn--cancel {
				background: transparent; color: var(--rs-text-secondary);
				border: 1px solid var(--rs-border);
			}
			.url-popover__btn--cancel:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }

			/* ── Slash Menu ── */
			.slash-menu {
				position: absolute; z-index: 100;
				background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
				border-radius: 10px; box-shadow: var(--rs-shadow-lg);
				max-height: 360px; overflow-y: auto; min-width: 240px;
				display: none;
			}
			.slash-menu__header {
				padding: 8px 12px 6px; font-size: 11px; font-weight: 600;
				text-transform: uppercase; letter-spacing: 0.05em;
				color: var(--rs-text-muted); border-bottom: 1px solid var(--rs-border-subtle);
			}
			.slash-menu-item {
				display: flex; align-items: center; gap: 10px;
				padding: 8px 12px; cursor: pointer; transition: background 0.1s;
			}
			.slash-menu-item:last-child { border-radius: 0 0 10px 10px; }
			.slash-menu-item:hover, .slash-menu-item.selected { background: var(--rs-bg-hover); }
			.slash-menu-icon {
				width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
				background: var(--rs-bg-surface-raised); border-radius: 6px;
				font-size: 13px; font-weight: 600; color: var(--rs-primary);
				flex-shrink: 0;
			}
			.slash-menu-icon svg { width: 16px; height: 16px; }
			.slash-menu-text { flex: 1; }
			.slash-menu-title { font-size: 13px; font-weight: 500; color: var(--rs-text-primary); }
			.slash-menu-desc { font-size: 11px; color: var(--rs-text-muted); }
			.slash-menu-hint {
				font-size: 10px; color: var(--rs-text-muted); padding: 1px 6px;
				background: var(--rs-bg-surface-raised); border-radius: 3px; margin-left: auto;
			}

			/* ── Code highlighting (lowlight) ── */
			.tiptap-container .tiptap .hljs-keyword { color: #c792ea; }
			.tiptap-container .tiptap .hljs-string { color: #c3e88d; }
			.tiptap-container .tiptap .hljs-number { color: #f78c6c; }
			.tiptap-container .tiptap .hljs-comment { color: #546e7a; font-style: italic; }
			.tiptap-container .tiptap .hljs-built_in { color: #82aaff; }
			.tiptap-container .tiptap .hljs-function { color: #82aaff; }
			.tiptap-container .tiptap .hljs-title { color: #82aaff; }
			.tiptap-container .tiptap .hljs-attr { color: #ffcb6b; }
			.tiptap-container .tiptap .hljs-tag { color: #f07178; }
			.tiptap-container .tiptap .hljs-type { color: #ffcb6b; }
		`;
	}
}

customElements.define("folk-notes-app", FolkNotesApp);
