/**
 * <folk-notes-app> — notebook and note management.
 *
 * Browse notebooks, create/edit notes with rich text,
 * 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';

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;
	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; 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 = "";

	// 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;

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

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

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

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

		const tripPlanningNotes: Note[] = [
			{
				id: "demo-note-1", title: "Pre-trip Preparation",
				content: `## Pre-trip Preparation

### Flights & Transfers
- **Jul 6**: Fly Geneva, shuttle to Chamonix (~1.5h)
- **Jul 14**: Train Zermatt to Dolomites (Bernina Express, ~6h scenic route)
- **Jul 20**: Fly home from Innsbruck

> Book the Aiguille du Midi cable car tickets at least 2 weeks in advance -- they sell out fast in July.

### Travel Documents
1. Passports (valid 6+ months)
2. EU health insurance cards (EHIC)
3. Travel insurance policy (ref: WA-2026-7891)
4. Hut reservation confirmations (printed copies)
5. Drone registration for Italy

### Budget Overview
Total budget: **EUR 4,000** across 4 travelers.

\`\`\`
Transport:     EUR  800 (20%)
Accommodation: EUR 1200 (30%)
Activities:    EUR 1000 (25%)
Food:          EUR  600 (15%)
Gear:          EUR  400 (10%)
\`\`\`

*Maya is tracking expenses in rFunds. Current spend: EUR 1,203.*`,
				content_plain: "Pre-trip preparation checklist covering flights, transfers, travel documents, and budget overview for the Alpine Explorer 2026 trip.",
				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: `## Accommodation Research

### Chamonix (Jul 6-10)
- **Refuge du Lac Blanc** -- Jul 7, 4 beds, conf #LB2026-234
- Airbnb in town for other nights (~EUR 120/night for 4 pax)
- Consider Hotel Le Morgane if Airbnb falls through

### Zermatt (Jul 10-14)
- **Hornlihutte** (Matterhorn base) -- Waitlisted for Jul 12
- Main accommodation: Apartment near Bahnhofstrasse
- Car-free village, arrive by Glacier Express

> Zermatt is expensive. Budget EUR 80-100pp/night minimum. The apartment saves us about 40% vs hotels.

### Dolomites (Jul 14-20)
- **Rifugio Locatelli** -- Jul 15, 4 beds, conf #TRE2026-089
- Val Gardena base: Ortisei area
- Look for agriturismo options for authentic experience

### Booking Status
| Location | Status | Cost/Night | Notes |
|----------|--------|-----------|-------|
| Lac Blanc | Confirmed | EUR 65pp | Half-board included |
| Chamonix Airbnb | Confirmed | EUR 120 total | 2-bed apartment |
| Zermatt apartment | Confirmed | EUR 180 total | Near station |
| Hornlihutte | Waitlisted | EUR 85pp | Fingers crossed |
| Locatelli | Confirmed | EUR 55pp | Half-board |
| Val Gardena | Searching | ~EUR 100 total | Need 4+ beds |`,
				content_plain: "Accommodation research for all three destinations: Chamonix, Zermatt, and Dolomites. Includes confirmed bookings, waitlists, and budget estimates.",
				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: `## Activity Planning

### Hiking Routes
- **Lac Blanc** (Jul 7) -- Acclimatization hike, ~6h round trip, 1000m elevation gain. Stunning Mont Blanc reflection at sunrise.
- **Gornergrat Sunrise** (Jul 11) -- Take the first train up at 7am, hike down. Matterhorn panorama.
- **Matterhorn Base Camp** (Jul 12) -- Full day trek to Hornlihutte. 1500m gain. Only if weather permits.
- **Tre Cime di Lavaredo** (Jul 15) -- Classic loop, ~4h. Stay at Rifugio Locatelli for golden hour photos.
- **Seceda Ridgeline** (Jul 17) -- Gondola up, ridge walk, hike down to Ortisei. Best drone location.

### Adventure Activities
1. **Via Ferrata at Aiguille du Midi** (Jul 8) -- Rent harness + lanyard + helmet in Chamonix, ~EUR 25/day
2. **Paragliding over Zermatt** (Jul 13) -- Tandem flights ~EUR 180pp. Book with Paragliding Zermatt (best reviews)
3. **Kayaking at Lago di Braies** (Jul 16) -- Turquoise glacial lake, kayak rental ~EUR 15/hour

### Rest Days
- Jul 9: Explore Chamonix town, gear shopping
- Jul 19: Free day before flying home, packing

> Omar suggested we vote on the Day 5 alternative activity -- Via Ferrata is winning 7-3 over kayaking on Lac d'Annecy.

### Difficulty Ratings
\`\`\`
Lac Blanc:        Moderate  (T2)
Gornergrat:       Easy      (T1)
Matterhorn Base:  Difficult (T3)
Tre Cime Loop:    Moderate  (T2)
Seceda Ridge:     Easy      (T1)
Via Ferrata:      Difficult (K3)
\`\`\``,
				content_plain: "Detailed activity planning including hiking routes with difficulty ratings, adventure activities with costs, and rest day plans.",
				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: `## Gear Research

### Via Ferrata Kit
Need harness + lanyard + helmet. Can rent in Chamonix for ~EUR 25/day per person. Renting is better than buying for a one-time activity.

### Paragliding
Tandem flights in Zermatt: ~EUR 180pp. Book at **Paragliding Zermatt** (best reviews on TripAdvisor). They offer morning flights with better thermals.

### Camera & Drone
- Bring the **DJI Mini 4 Pro** for Tre Cime and Seceda
- Check Italian drone regulations! Need ENAC registration for flights over 250g
- ND filters for long exposure water shots at Lago di Braies
- Extra batteries (3x) -- cold altitude drains them fast

> Liam scored the DJI Mini 4 Pro over GoPro Hero 12 and Sony A7C II in our decision matrix. Best weight-to-quality ratio for hiking.

### Group Gear (Shared)
\`\`\`
Item                        Cost     Status      Owner
--------------------------  -------  ----------  -----
Adventure First-Aid Kit     EUR  85  Funded      Omar
Water Filter (Sawyer)       EUR  45  Funded      Maya
Bear Canisters 2x (BV500)  EUR 120  In Cart     Liam
Camp Stove + Fuel           EUR  65  Funded      Priya
DJI Mini 4 Pro Rental       EUR 350  Needs Fund  Liam
Starlink Mini Rental        EUR 200  Needs Fund  Omar
\`\`\`

### Personal Gear Checklist
- Hiking boots (broken in!)
- Rain jacket (waterproof, not just resistant)
- Headlamp + spare batteries
- Trekking poles (collapsible for flights)
- Sunscreen SPF 50 + lip balm
- Wool base layers for hut nights`,
				content_plain: "Gear research including Via Ferrata rental, paragliding booking, camera and drone regulations, shared group gear status, and personal gear checklist.",
				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: `## Emergency Contacts & Safety

### Emergency Numbers
- **France**: 112 (EU general), PGHM Mountain Rescue: +33 4 50 53 16 89
- **Switzerland**: 1414 (REGA air rescue), 144 (ambulance)
- **Italy**: 118 (medical), 112 (general emergency)

### Insurance
- Policy #: **WA-2026-7891**
- Emergency line: +1-800-555-0199
- Covers: mountain rescue, helicopter evacuation, medical repatriation
- Deductible: EUR 150 per incident

### Altitude Sickness Protocol
1. Acclimatize in Chamonix (1,035m) for 2 days before going high
2. Stay hydrated -- minimum 3L water per day above 2,500m
3. Watch for symptoms: headache, nausea, dizziness
4. Descend immediately if symptoms worsen
5. Omar packed altitude sickness medication (Diamox) in the first-aid kit

### Weather Contingency
> If the Matterhorn trek gets rained out, Omar found a cheese museum in Zermatt as backup. Priya suggests the Glacier Paradise instead.

- Check MeteoSwiss and Meteotrentino daily
- Alpine weather changes fast -- always carry rain gear
- Lightning protocol: descend ridgelines immediately, avoid isolated trees

### Emergency Meeting Points
\`\`\`
Chamonix:   Place Balmat (town center)
Zermatt:    Bahnhofplatz (train station)
Dolomites:  Rifugio Auronzo parking lot
\`\`\``,
				content_plain: "Emergency contacts for France, Switzerland, and Italy. Insurance details, altitude sickness protocol, weather contingency plans, and emergency meeting points.",
				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: `## Photo Spots & Creative Plan

### Must-Capture Locations
1. **Lac Blanc** -- Reflection of Mont Blanc at sunrise. Arrive by 5:30am. Tripod essential.
2. **Gornergrat Panorama** -- 360-degree view with Matterhorn. Golden hour is best.
3. **Tre Cime from Rifugio Locatelli** -- The iconic three peaks at golden hour. Drone shots here.
4. **Seceda Ridgeline** -- Dramatic Dolomite spires. Best drone footage location.
5. **Lago di Braies** -- Turquoise water, use ND filters for long exposure reflections.

### Drone Shot List (Liam)
- Tre Cime circular orbit (check wind < 20km/h)
- Seceda ridge reveal shot (low to high)
- Lago di Braies top-down turquoise pattern
- Matterhorn time-lapse from Gornergrat (if weather permits)

### Zine Plan (Maya)
We are making an **Alpine Explorer Zine** after the trip:
- Format: A5 risograph, 50 copies
- Print at Chamonix Print Collective
- Content: best photos, trail notes, hand-drawn maps
- Price: EUR 12 per copy on rCart

> Bring ND filters for long exposure water shots. Pack the circular polarizer for cutting glare on alpine lakes.

### Video Plan
- Daily 30-second clips for trip recap
- Lac Blanc sunrise test footage already uploaded to rTube (3:42)
- Final edit: 5-8 minute highlight reel`,
				content_plain: "Photography and creative plan including must-capture locations, drone shot list, zine production details, and video plan.",
				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: `## Packing Checklist

### Footwear
- [x] Hiking boots (broken in!)
- [ ] Camp sandals / flip-flops
- [ ] Extra laces

### Clothing
- [x] Rain jacket (Gore-Tex)
- [ ] Down jacket for hut nights
- [ ] 3x wool base layers
- [ ] 2x hiking pants
- [ ] Sun hat + warm beanie
- [ ] Gloves (lightweight)

### Gear
- [x] Headlamp + spare batteries
- [ ] Trekking poles (collapsible)
- [x] First aid kit
- [ ] Sunscreen SPF 50
- [ ] Water filter (Sawyer Squeeze)
- [ ] Dry bags (2x)
- [ ] Repair kit (duct tape, zip ties)

### Electronics
- [ ] Camera + 3 batteries
- [ ] Drone + 3 batteries (Liam)
- [x] Power bank (20,000mAh)
- [ ] Universal adapter (EU plugs)

### Documents
- [x] Passports
- [ ] Travel insurance printout
- [x] Hut reservation confirmations
- [ ] Italian drone registration
- [ ] Emergency contacts card

### Food & Water
- [ ] Reusable water bottles (1L each)
- [ ] Trail snacks (energy bars, nuts)
- [ ] Electrolyte tablets
- [ ] Coffee/tea for hut mornings`,
				content_plain: "Complete packing checklist organized by category: footwear, clothing, gear, electronics, documents, and food. Includes checked-off items.",
				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: `## Food & Cooking Plan

### Hut Meals (Half-Board)
Lac Blanc and Locatelli include dinner + breakfast. Budget EUR 0 for those nights.

### Self-Catering Days
We have a kitchen in the Chamonix Airbnb and Zermatt apartment.

**Chamonix Grocery Run (Omar)**
- Pasta, rice, couscous
- Cheese, bread, cured meats
- Fresh vegetables
- Coffee, tea, milk
- Trail mix ingredients

> Omar already spent EUR 93 at Chamonix Carrefour. Receipts in rFunds.

### Trail Lunches
Pack these the night before each hike:
- Sandwiches (baguette + cheese + ham)
- Energy bars (2 per person)
- Nuts and dried fruit
- Chocolate (the altitude calls for it)
- 1.5L water minimum

### Special Meals
- **Jul 10**: Fondue at Chez Vrony, Zermatt (won the dinner vote 5-4 over pizza)
- **Jul 18**: Cooking class in Bolzano (South Tyrolean cuisine)

### Dietary Notes
\`\`\`
Maya:  Vegetarian (eggs & dairy OK)
Priya: No shellfish allergy
Others: No restrictions
\`\`\``,
				content_plain: "Food and cooking plan covering hut meals, self-catering, trail lunches, special restaurant meals, and dietary notes for all travelers.",
				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: `## Transport & Logistics

### Getting There
- **Jul 6**: Fly to Geneva (everyone arrives by 14:00)
- Geneva to Chamonix shuttle: EUR 186 for 4 pax (Maya paid, tracked in rFunds)

### Between Destinations
- **Jul 10**: Chamonix to Zermatt
  - Option A: Train via Martigny (~3.5h, scenic)
  - Option B: Drive via Grand St Bernard tunnel (~2.5h)
  - *Decision: Train (Liam prefers scenic route)*

- **Jul 14**: Zermatt to Dolomites
  - Bernina Express is 6 hours but spectacular
  - Rental car is 3.5 hours
  - *Under discussion in rForum -- Priya prefers the train*

### Local Transport
- **Chamonix**: Free local bus with guest card
- **Zermatt**: Car-free! Electric taxis + Gornergrat railway
- **Dolomites**: Need rental car or local bus (limited schedule)

### Getting Home
- **Jul 20**: Drive to Innsbruck airport (~2.5h from Val Gardena)
- Return flights booked separately

### Important Timetables
\`\`\`
Gornergrat Railway (first):  07:00 from Zermatt
Lac Blanc trailhead shuttle: 06:30 from Chamonix
Seceda gondola (first):      08:30 from Ortisei
\`\`\`

> Sam is researching rail passes -- the Swiss Travel Pass might save us money for the Zermatt segment.`,
				content_plain: "Transport and logistics plan covering flights, inter-city transfers, local transport options, return journey, and important timetables.",
				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: `## Full Itinerary -- Alpine Explorer 2026
**Jul 6-20 | France, Switzerland, Italy**
**Travelers: Maya, Liam, Priya, Omar, Alex, Sam**

### Week 1: Chamonix, France (Jul 6-10)
| Date | Activity | Category |
|------|----------|----------|
| Jul 6 | Fly Geneva, shuttle to Chamonix | Travel |
| Jul 7 | Acclimatization hike -- Lac Blanc | Hike |
| Jul 8 | Via Ferrata -- Aiguille du Midi | Adventure |
| Jul 9 | Rest day / Chamonix town | Rest |
| Jul 10 | Train to Zermatt | Travel |

### Week 2: Zermatt, Switzerland (Jul 10-14)
| Date | Activity | Category |
|------|----------|----------|
| Jul 10 | Arrive Zermatt, settle in | Travel |
| Jul 11 | Gornergrat sunrise hike | Hike |
| Jul 12 | Matterhorn base camp trek | Hike |
| Jul 13 | Paragliding over Zermatt | Adventure |
| Jul 14 | Transfer to Dolomites | Travel |

### Week 3: Dolomites, Italy (Jul 14-20)
| Date | Activity | Category |
|------|----------|----------|
| Jul 14 | Arrive Val Gardena | Travel |
| Jul 15 | Tre Cime di Lavaredo loop | Hike |
| Jul 16 | Lago di Braies kayaking | Adventure |
| Jul 17 | Seceda ridgeline hike | Hike |
| Jul 18 | Cooking class in Bolzano | Culture |
| Jul 19 | Free day -- shopping & packing | Rest |
| Jul 20 | Fly home from Innsbruck | Travel |

> This itinerary is also tracked in rTrips and synced to the shared rCal calendar.`,
				content_plain: "Complete day-by-day itinerary for the Alpine Explorer 2026 trip covering three weeks across Chamonix, Zermatt, and the Dolomites.",
				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: `## Mountain Hut Reservations

### Confirmed
- **Refuge du Lac Blanc** (Jul 7)
  - 4 beds reserved, half-board
  - Confirmation: #LB2026-234
  - Check-in after 15:00, dinner at 19:00
  - Bring sleeping bag liner (required)

- **Rifugio Locatelli** (Jul 15)
  - 4 beds reserved, half-board
  - Confirmation: #TRE2026-089
  - Famous for Tre Cime sunset views
  - Cash only! Bring EUR 55pp

### Waitlisted
- **Hornlihutte** (Matterhorn base, Jul 12)
  - Waitlisted -- will know by Jul 1
  - If no luck, camp at Zermatt and do a long day hike instead
  - Alex is monitoring the cancellation list

### Hut Etiquette Reminders
1. Arrive before 17:00 if possible
2. Remove boots at entrance (bring hut shoes or thick socks)
3. Lights out by 22:00
4. Pack out all trash
5. Tip is appreciated but not required

> Priya handled all the hut bookings. She has the confirmations printed and digital copies in rFiles.`,
				content_plain: "Mountain hut reservations with confirmation numbers, check-in details, and hut etiquette reminders. Two confirmed, one waitlisted.",
				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: `## Group Decisions & Votes

### Decided
- **Camera Gear**: DJI Mini 4 Pro (Liam's decision matrix: 8.5/10)
- **First Night Dinner in Zermatt**: Fondue at Chez Vrony (won 5-4 over pizza)
- **Day 5 Activity**: Via Ferrata at Aiguille du Midi (won 7-3 over kayaking)

### Active Votes (in rVote)
- **Zermatt to Dolomites transfer**: Train vs rental car
  - Train: 3 votes (Liam, Priya, Sam)
  - Car: 2 votes (Maya, Omar)
  - Alex: undecided

### Pending Decisions
- Val Gardena accommodation (agriturismo vs apartment)
- Whether to rent the Starlink Mini (EUR 200, needs funding)
- Trip zine print run size (50 vs 100 copies)

### Decision Framework
We are using a simple majority vote for group activities. For expenses over EUR 100, we need consensus (all 6 agree). Individual expenses are each person's choice.

> All votes are tracked in rVote and synced to the canvas.`,
				content_plain: "Summary of group decisions made and active votes. Covers camera gear, dining, activities, and pending decisions with the group's voting framework.",
				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, space: "demo",
			} 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, space: "demo",
			} 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, space: "demo",
			} 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;
		this.render();
	}

	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[],
			space: "demo",
		} 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: "",
			type: "NOTE", tags: null, is_pinned: false,
			created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
		};
		// Add to the matching demoNotebook
		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.render();
	}

	disconnectedCallback() {
		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;
			// Keepalive ping every 30s
			this.pingInterval = setInterval(() => {
				if (this.ws?.readyState === WebSocket.OPEN) {
					this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
				}
			}, 30000);
			// If we had a pending subscription, re-subscribe
			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));
				}
				// pong and other messages are ignored
			} catch {
				// ignore parse errors
			}
		};

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

		this.ws.onerror = () => {
			// onclose will fire after this
		};
	}

	private disconnectSync() {
		if (this.pingInterval) clearInterval(this.pingInterval);
		if (this.ws) {
			this.ws.onclose = null; // prevent reconnect
			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;

		// Send reply if needed
		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;

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

		// Send initial sync message to kick off handshake
		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; // doc not yet synced

		// 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 || "",
					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(),
				});
			}
		}

		// Sort: pinned first, then by sort order, then by updated_at desc
		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 specific note, update it from doc too
		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 || "",
					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: "",
				type: "NOTE",
				tags: [],
				isPinned: false,
				sortOrder: 0,
				createdAt: now,
				updatedAt: now,
			};
		});

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

		// Open the new note for editing
		this.selectedNote = {
			id: noteId, title: "Untitled Note", content: "", content_plain: "",
			type: "NOTE", tags: null, is_pinned: false,
			created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
		};
		this.view = "note";
		this.render();
	}

	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 match = window.location.pathname.match(/^\/([^/]+)\/rnotes/);
		if (match) return `/${match[1]}/rnotes`;
		if (this.space) return `/${this.space}/rnotes`;
		return "";
	}

	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();

		// Unsubscribe from any previous notebook
		this.unsubscribeNotebook();

		// Subscribe to the new notebook via Automerge
		this.subscribeNotebook(id);

		// Set a timeout — if doc doesn't arrive in 5s, fall back to REST
		setTimeout(() => {
			if (this.loading && this.view === "notebook") {
				this.loadNotebookREST(id);
			}
		}, 5000);
	}

	/** REST fallback for notebook detail */
	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 — just select it
		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 || "",
				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) {
			// Fallback: find in REST-loaded data
			this.selectedNote = this.selectedNotebook.notes.find(n => n.id === id) || null;
		}
		this.render();
	}

	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();
		}
	}

	private getNoteIcon(type: string): string {
		switch (type) {
			case "NOTE": return "📝";
			case "CODE": return "💻";
			case "BOOKMARK": return "🔗";
			case "IMAGE": return "🖼";
			case "AUDIO": return "🎤";
			case "FILE": return "📎";
			case "CLIP": return "✂️";
			default: return "📄";
		}
	}

	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();
	}

	private render() {
		this.shadow.innerHTML = `
		<style>
			:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
			* { box-sizing: border-box; }

			.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 rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
			.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
			.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
			.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
			.rapp-nav__btn:hover { background: #6366f1; }

			.search-bar {
				width: 100%; padding: 10px 14px; border-radius: 8px;
				border: 1px solid #444; background: #2a2a3e; color: #e0e0e0;
				font-size: 14px; margin-bottom: 16px;
			}
			.search-bar:focus { border-color: #6366f1; outline: none; }

			.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
			.notebook-card {
				border-radius: 10px; padding: 16px; cursor: pointer;
				border: 2px solid transparent; transition: border-color 0.2s;
				min-height: 120px; display: flex; flex-direction: column; justify-content: space-between;
			}
			.notebook-card:hover { border-color: rgba(255,255,255,0.2); }
			.notebook-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
			.notebook-meta { font-size: 12px; opacity: 0.7; }

			.note-item {
				background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
				padding: 12px 16px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.2s;
				display: flex; gap: 12px; align-items: flex-start;
			}
			.note-item:hover { border-color: #555; }
			.note-icon { font-size: 20px; flex-shrink: 0; }
			.note-body { flex: 1; min-width: 0; }
			.note-title { font-size: 14px; font-weight: 600; }
			.note-preview { font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
			.note-meta { font-size: 11px; color: #666; margin-top: 4px; display: flex; gap: 8px; }
			.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #333; color: #aaa; font-size: 10px; }
			.pinned { color: #f59e0b; }

			.note-content {
				background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
				padding: 20px; font-size: 14px; line-height: 1.6;
			}
			.note-content[contenteditable] { outline: none; min-height: 100px; cursor: text; }
			.note-content[contenteditable]:focus { border-color: #6366f1; }

			.editable-title {
				background: transparent; border: none; color: inherit; font: inherit;
				font-size: 18px; font-weight: 600; width: 100%; outline: none;
				padding: 0; flex: 1;
			}
			.editable-title:focus { border-bottom: 1px solid #6366f1; }

			.sync-badge {
				display: inline-block; width: 8px; height: 8px; border-radius: 50%;
				margin-left: 8px; vertical-align: middle;
			}
			.sync-badge.connected { background: #10b981; }
			.sync-badge.disconnected { background: #ef4444; }

			.empty { text-align: center; color: #666; padding: 40px; }
			.loading { text-align: center; color: #888; padding: 40px; }
			.error { text-align: center; color: #ef5350; padding: 20px; }
		</style>

		${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
		${this.loading ? '<div class="loading">Loading...</div>' : ""}
		${!this.loading ? this.renderView() : ""}
		`;

		this.attachListeners();
	}

	private renderView(): string {
		if (this.view === "note" && this.selectedNote) return this.renderNote();
		if (this.view === "notebook" && this.selectedNotebook) return this.renderNotebook();
		return this.renderNotebooks();
	}

	private renderNotebooks(): string {
		return `
			<div class="rapp-nav">
				<span class="rapp-nav__title">Notebooks</span>
				<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)}">

			${this.searchQuery && this.searchResults.length > 0 ? `
				<div style="margin-bottom:16px;font-size:13px;color:#888">${this.searchResults.length} results for "${this.esc(this.searchQuery)}"</div>
				${this.searchResults.map((n) => this.renderNoteItem(n)).join("")}
			` : ""}

			${!this.searchQuery ? `
				<div class="grid">
					${this.notebooks.map((nb) => `
						<div class="notebook-card" data-notebook="${nb.id}"
							style="background:${nb.cover_color}33;border-color:${nb.cover_color}55">
							<div>
								<div class="notebook-title">${this.esc(nb.title)}</div>
								<div class="notebook-meta">${this.esc(nb.description || "")}</div>
							</div>
							<div class="notebook-meta">${nb.note_count} notes &middot; ${this.formatDate(nb.updated_at)}</div>
						</div>
					`).join("")}
				</div>
				${this.notebooks.length === 0 ? '<div class="empty">No notebooks yet. Create one to get started.</div>' : ""}
			` : ""}
		`;
	}

	private renderNotebook(): string {
		const nb = this.selectedNotebook!;
		const syncBadge = this.subscribedDocId
			? `<span class="sync-badge ${this.syncConnected ? "connected" : "disconnected"}" title="${this.syncConnected ? "Live sync" : "Reconnecting..."}"></span>`
			: "";
		return `
			<div class="rapp-nav">
				<button class="rapp-nav__back" data-back="notebooks">← Notebooks</button>
				<span class="rapp-nav__title" style="color:${nb.cover_color}">${this.esc(nb.title)}${syncBadge}</span>
				<button class="rapp-nav__btn" id="create-note">+ New Note</button>
			</div>
			${nb.notes && nb.notes.length > 0
				? nb.notes.map((n) => this.renderNoteItem(n)).join("")
				: '<div class="empty">No notes in this notebook.</div>'
			}
		`;
	}

	private renderNoteItem(n: Note): string {
		return `
			<div class="note-item" data-note="${n.id}">
				<span class="note-icon">${this.getNoteIcon(n.type)}</span>
				<div class="note-body">
					<div class="note-title">${n.is_pinned ? '<span class="pinned">&#x1F4CC;</span> ' : ""}${this.esc(n.title)}</div>
					<div class="note-preview">${this.esc(n.content_plain || "")}</div>
					<div class="note-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 renderNote(): string {
		const n = this.selectedNote!;
		const isDemo = this.space === "demo";
		const isAutomerge = !!(this.doc?.items?.[n.id]);
		const isEditable = isAutomerge || isDemo;
		return `
			<div class="rapp-nav">
				<button class="rapp-nav__back" data-back="${this.selectedNotebook ? "notebook" : "notebooks"}">← ${this.selectedNotebook ? this.esc(this.selectedNotebook.title) : "Notebooks"}</button>
				${isEditable
					? `<input class="editable-title" id="note-title-input" value="${this.esc(n.title)}" placeholder="Note title...">`
					: `<span class="rapp-nav__title">${this.getNoteIcon(n.type)} ${this.esc(n.title)}</span>`
				}
			</div>
			<div class="note-content" ${isEditable ? 'contenteditable="true" id="note-content-editable"' : ""}>${n.content || '<em style="color:#666">Empty note</em>'}</div>
			<div style="margin-top:12px;font-size:12px;color:#666;display:flex;gap:12px">
				<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 style="color:#10b981">Live</span>' : ""}
				${isDemo ? '<span style="color:#f59e0b">Demo</span>' : ""}
			</div>
		`;
	}

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

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

		// Create note (Automerge or demo)
		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
		this.shadow.querySelectorAll("[data-back]").forEach((el) => {
			el.addEventListener("click", (e) => {
				e.stopPropagation();
				const target = (el as HTMLElement).dataset.back;
				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.render(); }
			});
		});

		// Editable note title (debounced) — demo: update local data; live: Automerge
		const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement;
		if (titleInput && this.selectedNote) {
			let titleTimeout: any;
			const noteId = this.selectedNote.id;
			titleInput.addEventListener("input", () => {
				clearTimeout(titleTimeout);
				titleTimeout = setTimeout(() => {
					if (isDemo) {
						this.demoUpdateNoteField(noteId, "title", titleInput.value);
					} else {
						this.updateNoteField(noteId, "title", titleInput.value);
					}
				}, 500);
			});
		}

		// Editable note content (debounced) — demo: update local data; live: Automerge
		const contentEl = this.shadow.getElementById("note-content-editable");
		if (contentEl && this.selectedNote) {
			let contentTimeout: any;
			const noteId = this.selectedNote.id;
			contentEl.addEventListener("input", () => {
				clearTimeout(contentTimeout);
				contentTimeout = setTimeout(() => {
					const html = contentEl.innerHTML;
					const plain = contentEl.textContent?.trim() || "";
					if (isDemo) {
						this.demoUpdateNoteField(noteId, "content", html);
						this.demoUpdateNoteField(noteId, "content_plain", plain);
					} else {
						this.updateNoteField(noteId, "content", html);
						this.updateNoteField(noteId, "contentPlain", plain);
					}
				}, 800);
			});
		}
	}

	private demoUpdateNoteField(noteId: string, field: string, value: string) {
		// Update in the selectedNote
		if (this.selectedNote && this.selectedNote.id === noteId) {
			(this.selectedNote as any)[field] = value;
			this.selectedNote.updated_at = new Date().toISOString();
		}
		// Update in the matching demoNotebook
		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;
			}
		}
		// Update in selectedNotebook notes
		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();
			}
		}
	}

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

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