← Notes results

Notes

Claude haiku 5.5 · full

◈Launch the experiment

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · 5387f5bcf6
# Task: Build a Notes Application

Build a polished, fully functional notes application (vanilla JS, no external assets, no CDN, no libraries) in the spirit of UpNote, Obsidian, OneNote, or Notion. The top priority is **product-grade UI quality and editor correctness** — this should look and behave like a real shipping notes app, not a demo.

## Core Specification

**Three-Pane Layout**
- **Notebook/sidebar pane:** notebooks (or folders) grouping notes, with note lists under them; create/rename/delete notebooks; collapsible; an "All Notes" virtual view and a Trash
- **Note list pane:** notes in the selected notebook — title, snippet preview, updated timestamp, sort order toggle (modified / created / alphabetical); pinned notes float to the top
- **Editor pane:** the selected note, editing in place
- Panes are resizable via drag handles and the sidebar is collapsible; layout state persists

**Editor**
- Rich text via `contenteditable` with a formatting toolbar: bold, italic, underline, strikethrough, inline code, H1/H2/H3, bulleted and numbered lists, checklist items with clickable checkboxes, blockquote, horizontal rule
- Toolbar buttons reflect the formatting state at the cursor (bold button active when selection is bold)
- Keyboard shortcuts: Ctrl/Cmd+B/I/U, standard list continuation on Enter, Escape-empty-list-item on Enter
- **Markdown-style input shortcuts:** typing `# ` at line start converts to H1, `- ` to a bullet list, `[] ` to a checklist item, `> ` to a blockquote
- Paste is sanitized to the supported formatting subset — no foreign markup or scripts survive pasting
- Undo/redo (Ctrl+Z / Ctrl+Shift+Z) that works reliably with the custom formatting, not just browser defaults

**Organization & Search**
- Move notes between notebooks (drag-and-drop onto sidebar notebooks, or a move menu)
- Pin/unpin, duplicate, and delete notes; deleted notes go to Trash and can be restored or permanently deleted
- Global search: live filter across note titles and body text, with matched-text highlighting in results; search must cover the text content, not raw HTML tags
- Tags: add multiple tags per note; clicking a tag filters the note list

**Persistence**
- Everything — notebooks, notes (with formatting), tags, pins, trash, sort order, selection, and pane layout — serializes to `localStorage` and restores exactly on reload
- Auto-save as you type (debounced), with a subtle "Saved" indicator; never lose keystrokes

## Visual Style
- Refined productivity-app aesthetic: warm light theme (plus a dark theme toggle), elegant serif or clean sans reading typography in the editor, restrained chrome
- Clear visual hierarchy between the three panes; selected states obvious; hover states on every interactive element
- Micro-interactions: smooth sidebar collapse, note selection transitions, a tasteful empty state when no note is selected ("Select or create a note")
- This prompt is judged heavily on look and feel — sweat the spacing, typography, and focus states

## Technical Requirements
- Vanilla JS; all code organized with clear section comments
- Single source of truth: a serializable data model (notebooks, notes, tags) — the DOM is a projection of it, never the other way around
- Note content stored as sanitized HTML; a pure text-extraction function feeds search and snippets
- Tunable constants block at the top (colors, fonts, autosave debounce, snippet length)

## Quality Bar (must hit all)
- Formatting round-trips: bold a word, reload, it's still bold; checklist check states persist
- Search finds text inside formatted content (e.g. a word inside a bold span) and never matches tag names like "b" or "div"
- Enter at the end of a list item continues the list; Enter on an empty item exits it
- Deleting a note, then restoring it from Trash, returns it to its original notebook with formatting intact
- Markdown shortcuts trigger only at line start and convert immediately on the trailing space

## Self-Check Before Finishing
Verify with the browser tools (probe by setting editor content, dispatching input/keydown events, and asserting the model + DOM):
- Type a checklist item, toggle its checkbox, reload, and assert the checked state survived
- Apply bold to a word, search for that word, and assert it appears in results
- Type `# Hello` at a line start and assert an H1 is produced; type `#` mid-sentence and assert it stays literal text
- Delete a note, assert it's in Trash, restore it, and assert it reappears in its notebook
- Paste HTML containing a `<script>` tag and assert it is stripped
Inspect original source 81,786 bytes · SHA-256 4a985f26f006
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Notes</title>
<style>
/* =====================================================================
   1. THEME TOKENS — tune colours, fonts and radii here
   ===================================================================== */
:root{
  --bg:#f5f1e9;
  --side:#ece6da;
  --list:#f9f6f0;
  --editor:#fffdf9;
  --ink:#2b2622;
  --ink-2:#6a6056;
  --ink-3:#9b9084;
  --line:#e2d9c9;
  --hover:rgba(110,80,40,.075);
  --sel:#f0e2cf;
  --accent:#b8632e;
  --accent-ink:#fffaf4;
  --accent-soft:rgba(184,99,46,.14);
  --mark:#f5d88a;
  --danger:#b4412f;
  --shadow:0 1px 2px rgba(60,40,20,.06),0 6px 20px rgba(60,40,20,.08);
  --font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,"Helvetica Neue",Arial,sans-serif;
  --font-serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
  --font-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
[data-theme="dark"]{
  --bg:#171614;
  --side:#1d1c1a;
  --list:#211f1d;
  --editor:#1c1b19;
  --ink:#ece6dc;
  --ink-2:#b5ada2;
  --ink-3:#7f776d;
  --line:#34302b;
  --hover:rgba(255,240,220,.06);
  --sel:#352e27;
  --accent:#e08a55;
  --accent-ink:#1c1a18;
  --accent-soft:rgba(224,138,85,.18);
  --mark:#6d5b1f;
  --danger:#e56d55;
  --shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px rgba(0,0,0,.35);
}

/* =====================================================================
   2. BASE + LAYOUT
   ===================================================================== */
*,*::before,*::after{box-sizing:border-box}
html,body{height:100%;margin:0}
body{font-family:var(--font-ui);font-size:14px;color:var(--ink);background:var(--bg);overflow:hidden;-webkit-font-smoothing:antialiased;line-height:1.4}
button,input,select{font:inherit;color:inherit}
button{background:none;border:0;padding:0;cursor:pointer}
input{background:none;border:0;outline:none}
svg{display:block;flex:none}
.ico{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
:focus{outline:none}
:focus-visible{outline:2px solid var(--accent);outline-offset:1px;border-radius:6px}
[hidden]{display:none!important}

#app{display:grid;height:100vh;grid-template-columns:var(--w-side,232px) 6px var(--w-list,300px) 6px minmax(0,1fr);transition:grid-template-columns .28s cubic-bezier(.3,.7,.2,1)}
#app.side-collapsed{grid-template-columns:0px 0px var(--w-list,300px) 6px minmax(0,1fr)}

.handle{position:relative;cursor:col-resize;z-index:5}
.handle::after{content:'';position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:var(--line);transition:background .15s,width .15s}
.handle:hover::after,.handle.drag::after{background:var(--accent);width:2px}
#app.side-collapsed .handle-side{visibility:hidden}

/* ---------- sidebar ---------- */
.sidebar{background:var(--side);min-width:0;overflow:hidden}
.sidebar-inner{width:var(--w-side,232px);height:100%;display:flex;flex-direction:column}
.side-head{display:flex;align-items:center;justify-content:space-between;padding:14px 10px 8px 16px}
.brand{display:flex;align-items:center;gap:9px;font-family:var(--font-serif);font-size:17px;font-weight:600;letter-spacing:.01em}
.brand .logo{width:22px;height:22px;border-radius:6px;background:var(--accent);color:var(--accent-ink);display:grid;place-items:center}
.side-scroll{flex:1;overflow-y:auto;padding:2px 8px 12px;scrollbar-width:thin}
.nav-item{display:flex;align-items:center;gap:9px;width:100%;height:32px;padding:0 10px;border-radius:7px;color:var(--ink-2);text-align:left;white-space:nowrap;transition:background .15s,color .15s}
.nav-item:hover{background:var(--hover);color:var(--ink)}
.nav-item.active{background:var(--sel);color:var(--ink);font-weight:600}
.nav-item .label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
.nav-item .count{font-size:12px;color:var(--ink-3);font-variant-numeric:tabular-nums;font-weight:400}
.nav-item .rename{flex:1}
.section-head{display:flex;align-items:center;justify-content:space-between;padding:16px 8px 6px 10px;font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink-3)}
.chev{width:18px;height:18px;display:grid;place-items:center;border-radius:4px;transition:transform .2s ease,background .15s;color:var(--ink-3)}
.chev:hover{background:var(--hover);color:var(--ink)}
.chev.open{transform:rotate(90deg)}
.nb-notes{padding:2px 0 6px 22px;animation:slide .22s ease}
@keyframes slide{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
.nb-note{display:block;width:100%;text-align:left;height:26px;line-height:26px;padding:0 8px;border-radius:6px;color:var(--ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;transition:background .15s,color .15s}
.nb-note:hover{background:var(--hover);color:var(--ink-2)}
.nb-note.active{color:var(--ink);background:var(--sel)}
.nb.drop>.nav-item{background:var(--accent-soft);box-shadow:inset 0 0 0 1.5px var(--accent);color:var(--ink)}
.tag-hash{color:var(--accent);font-weight:600;width:12px;text-align:center}
.side-foot{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-top:1px solid var(--line)}
.side-foot small{font-size:11.5px;color:var(--ink-3)}

/* ---------- shared buttons ---------- */
.icon-btn{width:28px;height:28px;display:grid;place-items:center;border-radius:7px;color:var(--ink-2);transition:background .15s,color .15s,transform .1s}
.icon-btn:hover{background:var(--hover);color:var(--ink)}
.icon-btn:active{transform:scale(.94)}
.icon-btn.on{background:var(--accent-soft);color:var(--accent)}
.icon-btn:disabled{opacity:.35;pointer-events:none}
.btn{height:32px;padding:0 14px;border-radius:8px;background:var(--accent);color:var(--accent-ink);font-weight:600;font-size:13px;display:inline-flex;align-items:center;gap:7px;transition:filter .15s,transform .1s}
.btn:hover{filter:brightness(1.07)}
.btn:active{transform:scale(.98)}
.btn.ghost{background:var(--hover);color:var(--ink-2)}
.btn.ghost:hover{color:var(--ink)}

/* ---------- list pane ---------- */
.listpane{background:var(--list);display:flex;flex-direction:column;min-width:0;overflow:hidden}
.list-head{padding:14px 12px 10px 12px;display:flex;flex-direction:column;gap:10px;border-bottom:1px solid var(--line)}
.list-top{display:flex;align-items:center;gap:2px}
.list-title{flex:1;min-width:0;font-family:var(--font-serif);font-size:19px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-left:4px}
.search{position:relative}
.search input{width:100%;height:32px;border-radius:8px;background:var(--editor);border:1px solid var(--line);padding:0 28px 0 32px;font-size:13px;transition:border-color .15s,box-shadow .15s}
.search input::placeholder{color:var(--ink-3)}
.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.search>.ico{position:absolute;left:10px;top:8px;color:var(--ink-3);pointer-events:none}
.search .clear{position:absolute;right:5px;top:5px;width:22px;height:22px;display:grid;place-items:center;border-radius:6px;color:var(--ink-3)}
.search .clear:hover{background:var(--hover);color:var(--ink)}
.list-tools{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 2px}
.list-sub{font-size:12px;color:var(--ink-3)}
.sort select{appearance:none;-webkit-appearance:none;background:transparent;border:0;color:var(--ink-2);font-size:12px;padding:3px 18px 3px 6px;border-radius:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%239b9084' stroke-width='3'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 4px center}
.sort select:hover{background-color:var(--hover)}
.list-scroll{flex:1;overflow-y:auto;padding:6px 8px 16px;scrollbar-width:thin}
.note-item{position:relative;padding:10px 12px 10px 14px;border-radius:9px;cursor:pointer;margin-bottom:2px;transition:background .15s,box-shadow .2s}
.note-item:hover{background:var(--hover)}
.note-item.sel{background:var(--editor);box-shadow:var(--shadow)}
.note-item.sel::before{content:'';position:absolute;left:0;top:12px;bottom:12px;width:3px;border-radius:3px;background:var(--accent)}
.note-item.dragging{opacity:.45}
.ni-title{font-weight:600;font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:20px}
.ni-snip{color:var(--ink-2);font-size:12.5px;line-height:1.45;margin-top:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}
.ni-meta{display:flex;align-items:center;gap:6px;margin-top:6px;font-size:11px;color:var(--ink-3);min-width:0;white-space:nowrap;overflow:hidden}
.ni-meta .nbn{overflow:hidden;text-overflow:ellipsis}
.ni-pin{position:absolute;right:10px;top:11px;color:var(--accent)}
.ni-more{position:absolute;right:6px;top:30px;width:24px;height:24px;display:grid;place-items:center;border-radius:6px;color:var(--ink-3);opacity:0;transition:opacity .15s,background .15s}
.note-item:hover .ni-more,.note-item.sel .ni-more{opacity:1}
.ni-more:hover{background:var(--hover);color:var(--ink)}
.note-item:hover .ni-pin{display:none}
mark{background:var(--mark);color:inherit;border-radius:3px;padding:0 1px}
.mini-tag{background:var(--hover);padding:0 6px;border-radius:99px;font-size:10.5px;color:var(--ink-2);flex:none}
.list-empty{padding:60px 20px;text-align:center;color:var(--ink-3);font-size:13px}
.list-empty .ico{width:28px;height:28px;margin:0 auto 10px;opacity:.6}
.section-label-in{padding:10px 12px 4px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-3)}

/* ---------- editor pane ---------- */
.editorpane{background:var(--editor);min-width:0;display:flex;flex-direction:column;position:relative;overflow:hidden}
.ed-top{height:46px;display:flex;align-items:center;gap:2px;padding:0 10px 0 16px;border-bottom:1px solid var(--line);flex:none}
.crumbs{flex:1;min-width:0;font-size:12.5px;color:var(--ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.crumbs b{color:var(--ink-2);font-weight:500}
.save{font-size:12px;color:var(--ink-3);display:flex;align-items:center;gap:6px;margin-right:8px;white-space:nowrap}
.save .dot{width:6px;height:6px;border-radius:50%;background:#7fae7a;transition:background .2s}
.save.pending .dot{background:var(--accent);animation:pulse 1s infinite}
@keyframes pulse{50%{opacity:.25}}
.trash-banner{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 16px;background:var(--accent-soft);color:var(--ink-2);font-size:13px;border-bottom:1px solid var(--line);flex:none}
.toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:6px 12px;border-bottom:1px solid var(--line);flex:none}
.tb{min-width:30px;height:30px;padding:0 6px;display:inline-grid;place-items:center;border-radius:7px;color:var(--ink-2);font-size:13px;transition:background .12s,color .12s,transform .1s}
.tb:hover{background:var(--hover);color:var(--ink)}
.tb:active{transform:scale(.95)}
.tb.on{background:var(--accent-soft);color:var(--accent)}
.tb.txt{font-family:var(--font-ui);font-weight:700;font-size:12.5px;letter-spacing:.01em}
.tb.txt.i{font-style:italic;font-family:var(--font-serif);font-size:14px}
.tb.txt.u{text-decoration:underline;text-underline-offset:3px}
.tb.txt.s{text-decoration:line-through}
.tb.txt.code{font-family:var(--font-mono);font-weight:500;font-size:12px}
.tb-sep{width:1px;height:18px;background:var(--line);margin:0 6px}
.ed-scroll{flex:1;overflow-y:auto;scrollbar-width:thin}
.ed-wrap{max-width:760px;margin:0 auto;padding:40px 56px 160px;position:relative;animation:fadeIn .25s ease}
@keyframes fadeIn{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
.ed-title{width:100%;font-family:var(--font-serif);font-size:34px;font-weight:600;line-height:1.2;letter-spacing:-.01em;color:var(--ink);padding:0;margin:0}
.ed-title::placeholder{color:var(--ink-3)}
.ed-title:read-only{cursor:default}
.ed-tags{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:12px 0 24px;min-height:24px}
.tag-chip{display:inline-flex;align-items:center;gap:2px;height:22px;padding:0 3px 0 9px;border-radius:99px;background:var(--accent-soft);color:var(--accent);font-size:12px;font-weight:500;cursor:pointer;transition:filter .15s}
.tag-chip:hover{filter:brightness(1.05)}
.tag-chip .x{width:16px;height:16px;border-radius:50%;display:grid;place-items:center;opacity:.6}
.tag-chip .x:hover{opacity:1;background:rgba(0,0,0,.08)}
.tag-add{height:22px;width:130px;font-size:12px;color:var(--ink-2);padding:0 6px;border-radius:99px;transition:background .15s}
.tag-add:hover{background:var(--hover)}
.tag-add:focus{background:var(--hover)}
.tag-add::placeholder{color:var(--ink-3)}
.ed-body-wrap{position:relative}
.ph{position:absolute;top:0;left:0;color:var(--ink-3);font-family:var(--font-serif);font-size:17px;line-height:1.72;pointer-events:none;display:none}
.ph.show{display:block}
.ed-body{position:relative;font-family:var(--font-serif);font-size:17px;line-height:1.72;color:var(--ink);min-height:55vh;overflow-wrap:break-word;word-wrap:break-word;caret-color:var(--accent)}
.ed-body h1{font-size:30px;font-weight:700;line-height:1.25;margin:1.1em 0 .35em;letter-spacing:-.01em}
.ed-body h2{font-size:23px;font-weight:700;line-height:1.3;margin:1em 0 .3em}
.ed-body h3{font-size:19px;font-weight:600;line-height:1.35;margin:.9em 0 .25em}
.ed-body h1:first-child,.ed-body h2:first-child,.ed-body h3:first-child{margin-top:0}
.ed-body ul,.ed-body ol{margin:.35em 0;padding-left:1.6em}
.ed-body li{margin:.12em 0}
.ed-body ul.todo{list-style:none;padding-left:0}
.ed-body ul.todo>li{position:relative;padding-left:30px}
.ed-body ul.todo>li::before{content:'';position:absolute;left:2px;top:.3em;width:17px;height:17px;border:1.6px solid var(--ink-3);border-radius:5px;cursor:pointer;transition:background .15s,border-color .15s}
.ed-body ul.todo>li:hover::before{border-color:var(--accent)}
.ed-body ul.todo>li[data-done="true"]::before{background:var(--accent);border-color:var(--accent)}
.ed-body ul.todo>li[data-done="true"]::after{content:'';position:absolute;left:8px;top:calc(.3em + 3px);width:4px;height:8px;border:solid var(--accent-ink);border-width:0 2px 2px 0;transform:rotate(45deg)}
.ed-body ul.todo>li[data-done="true"]{color:var(--ink-3);text-decoration:line-through;text-decoration-color:var(--ink-3)}
.ed-body blockquote{margin:.6em 0;padding:.05em 0 .05em 16px;border-left:3px solid var(--accent);color:var(--ink-2);font-style:italic}
.ed-body blockquote>div{min-height:1.4em}
.ed-body code{font-family:var(--font-mono);font-size:.84em;background:var(--hover);border:1px solid var(--line);padding:.06em .36em;border-radius:5px}
.ed-body hr{border:0;height:1px;background:var(--line);margin:1.6em 0}
.ed-body s{text-decoration-thickness:1.5px}

/* ---------- empty state / banners / menus / toast ---------- */
.empty{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--ink-3);text-align:center;animation:fadeIn .3s ease}
.empty.show{display:flex}
.empty .big{width:58px;height:58px;border-radius:16px;background:var(--side);display:grid;place-items:center;color:var(--ink-2);margin-bottom:4px}
.empty p{font-family:var(--font-serif);font-size:18px;color:var(--ink-2);margin:0}
.empty small{font-size:12.5px}
.menu{position:fixed;z-index:100;min-width:220px;background:var(--editor);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:5px;animation:pop .14s ease}
@keyframes pop{from{opacity:0;transform:scale(.97) translateY(-2px)}to{opacity:1;transform:none}}
.menu-item{display:flex;align-items:center;gap:10px;width:100%;height:30px;padding:0 10px;border-radius:6px;text-align:left;color:var(--ink);font-size:13px;white-space:nowrap}
.menu-item:hover{background:var(--hover)}
.menu-item.danger{color:var(--danger)}
.menu-item .ck{margin-left:auto;color:var(--accent)}
.menu-item .ico{color:var(--ink-3)}
.menu-item.danger .ico{color:var(--danger)}
.menu-head{padding:7px 10px 3px;font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);font-weight:600}
.menu-sep{height:1px;background:var(--line);margin:5px 4px}
.rename{width:100%;height:24px;border-radius:5px;padding:0 6px;background:var(--editor);border:1px solid var(--accent);font-size:13px;box-shadow:0 0 0 3px var(--accent-soft)}
.toast{position:fixed;bottom:18px;left:50%;transform:translate(-50%,8px);background:var(--ink);color:var(--bg);padding:8px 14px;border-radius:8px;font-size:13px;box-shadow:var(--shadow);opacity:0;transition:opacity .2s,transform .2s;pointer-events:none;z-index:200}
.toast.show{opacity:1;transform:translate(-50%,0)}
.nav-item,.note-item,.icon-btn,.tb,.btn{-webkit-user-select:none;user-select:none}
.ed-body,.ed-title{-webkit-user-select:text;user-select:text}
</style>
</head>
<body>
<div id="app">
  <aside class="sidebar" id="sidebar"><div class="sidebar-inner" id="sideInner"></div></aside>
  <div class="handle handle-side" id="hSide" title="Drag to resize"></div>

  <section class="listpane" id="listPane">
    <div class="list-head">
      <div class="list-top">
        <button class="icon-btn" data-act="toggle-side" title="Toggle sidebar (Ctrl+\\)" id="sideToggle"></button>
        <div class="list-title" id="listTitle">All Notes</div>
        <button class="icon-btn" data-act="new-note" title="New note (Ctrl+N)" id="newNoteBtn"></button>
      </div>
      <div class="search">
        <input id="searchInput" type="search" placeholder="Search notes…" autocomplete="off" spellcheck="false">
        <button class="clear" id="searchClear" data-act="clear-search" title="Clear" hidden></button>
      </div>
      <div class="list-tools">
        <span class="list-sub" id="listSub"></span>
        <label class="sort"><select id="sortSel" title="Sort notes">
          <option value="modified">Date modified</option>
          <option value="created">Date created</option>
          <option value="alpha">Alphabetical</option>
        </select></label>
      </div>
    </div>
    <div class="list-scroll" id="listScroll"></div>
  </section>

  <div class="handle" id="hList" title="Drag to resize"></div>

  <main class="editorpane" id="editorPane">
    <div class="ed-top">
      <div class="crumbs" id="crumbs"></div>
      <span class="save" id="saveState"><span class="dot"></span><span id="saveText">Saved</span></span>
      <button class="icon-btn" data-act="pin" id="pinBtn" title="Pin / unpin"></button>
      <button class="icon-btn" data-act="note-menu" id="moveBtn" title="Move, duplicate, more"></button>
      <button class="icon-btn" data-act="dup" id="dupBtn" title="Duplicate"></button>
      <button class="icon-btn" data-act="trash" id="trashBtn" title="Move to Trash (Del)"></button>
    </div>
    <div class="trash-banner" id="trashBanner" hidden>
      <span>This note is in the Trash.</span>
      <button class="btn" data-act="restore">Restore note</button>
    </div>
    <div class="toolbar" id="toolbar">
      <button class="tb txt" data-act="bold" data-fmt="bold" title="Bold (Ctrl+B)">B</button>
      <button class="tb txt i" data-act="italic" data-fmt="italic" title="Italic (Ctrl+I)">I</button>
      <button class="tb txt u" data-act="underline" data-fmt="underline" title="Underline (Ctrl+U)">U</button>
      <button class="tb txt s" data-act="strike" data-fmt="strike" title="Strikethrough (Ctrl+Shift+S)">S</button>
      <button class="tb txt code" data-act="code" data-fmt="code" title="Inline code (Ctrl+E)">&lt;/&gt;</button>
      <span class="tb-sep"></span>
      <button class="tb txt" data-act="h1" data-fmt="h1" title="Heading 1">H1</button>
      <button class="tb txt" data-act="h2" data-fmt="h2" title="Heading 2">H2</button>
      <button class="tb txt" data-act="h3" data-fmt="h3" title="Heading 3">H3</button>
      <span class="tb-sep"></span>
      <button class="tb" data-act="ul" data-fmt="ul" title="Bulleted list"></button>
      <button class="tb" data-act="ol" data-fmt="ol" title="Numbered list"></button>
      <button class="tb" data-act="todo" data-fmt="todo" title="Checklist"></button>
      <span class="tb-sep"></span>
      <button class="tb" data-act="quote" data-fmt="quote" title="Blockquote"></button>
      <button class="tb" data-act="hr" title="Horizontal rule"></button>
    </div>
    <div class="ed-scroll" id="edScroll">
      <div class="ed-wrap" id="edWrap">
        <input class="ed-title" id="edTitle" placeholder="Untitled" autocomplete="off" spellcheck="false">
        <div class="ed-tags" id="edTags"></div>
        <div class="ed-body-wrap">
          <div class="ph" id="ph">Start writing, or type <b>#</b> for a heading, <b>-</b> for a list…</div>
          <div class="ed-body" id="editor" contenteditable="true" spellcheck="true" aria-label="Note body"></div>
        </div>
      </div>
    </div>
    <div class="empty" id="empty">
      <div class="big" id="emptyIcon"></div>
      <p>Select or create a note</p>
      <small>Your notes are saved automatically in this browser.</small>
      <button class="btn" data-act="new-note" style="margin-top:6px">New note</button>
    </div>
  </main>
</div>
<div class="toast" id="toast"></div>

<script>
'use strict';
/* =====================================================================
   3. CONFIG — tunable constants
   ===================================================================== */
const CONFIG = {
  STORAGE_KEY: 'notes-app.v1',
  AUTOSAVE_MS: 400,        // debounce for localStorage writes
  HISTORY_IDLE_MS: 350,    // typing bursts coalesce into one undo step
  HISTORY_LIMIT: 200,
  LIST_REFRESH_MS: 180,    // debounce for sidebar/list re-render while typing
  SNIPPET_LEN: 110,
  SIDE_MIN: 180, SIDE_MAX: 360, SIDE_DEFAULT: 232,
  LIST_MIN: 220, LIST_MAX: 480, LIST_DEFAULT: 300,
  DEFAULT_NOTEBOOK_NAMES: ['Personal', 'Work', 'Ideas'],
};

/* ---------- Icons (inline SVG paths, stroke based) ---------- */
const ICON = {
  chev:'<path d="M9 6l6 6-6 6"/>',
  folder:'<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
  note:'<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/>',
  layers:'<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 13l9 5 9-5"/>',
  trash:'<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>',
  pin:'<path d="M12 17v5"/><path d="M8 3h8l-1 6 3 4H6l3-4z"/>',
  tag:'<path d="M20.6 13.4l-7.2 7.2a2 2 0 0 1-2.8 0L3 12V3h9l8.6 8.6a2 2 0 0 1 0 2.8z"/><circle cx="7.5" cy="7.5" r="1.4"/>',
  plus:'<path d="M12 5v14M5 12h14"/>',
  search:'<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
  sidebar:'<rect x="3" y="4" width="18" height="16" rx="3"/><path d="M9 4v16"/>',
  sun:'<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
  moon:'<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
  more:'<circle cx="5" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="19" cy="12" r="1.4"/>',
  x:'<path d="M18 6L6 18M6 6l12 12"/>',
  dup:'<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/>',
  move:'<path d="M5 12h14M13 6l6 6-6 6"/>',
  restore:'<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>',
  check:'<path d="M20 6L9 17l-5-5"/>',
  edit:'<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>',
  bullet:'<circle cx="4" cy="6" r="1.3"/><circle cx="4" cy="12" r="1.3"/><circle cx="4" cy="18" r="1.3"/><path d="M9 6h11M9 12h11M9 18h11"/>',
  ordered:'<path d="M10 6h11M10 12h11M10 18h11"/><path d="M3.5 4.5L5 4v4M3 8h3M3.2 12.5a1.6 1.6 0 1 1 2.8 1L3 17.5h3"/>',
  todoIco:'<rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="M4.4 7.6l1.6 1.6 2.8-2.8"/><path d="M13 7.5h8M13 17.5h8"/><rect x="3" y="13.5" width="7" height="7" rx="1.5"/>',
  quote:'<path d="M7 17h3l2-4V7H6v6h3zM14 17h3l2-4V7h-6v6h3z"/>',
  hr:'<path d="M3 12h18"/>',
  code:'<path d="M16 18l6-6-6-6M8 6l-6 6 6 6"/>',
  sort:'<path d="M7 4v16M3 16l4 4 4-4M17 20V4M13 8l4-4 4 4"/>',
  clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
  back:'<path d="M15 18l-6-6 6-6"/>',
};
function ico(name, cls){ return `<svg class="ico${cls?' '+cls:''}" viewBox="0 0 24 24" aria-hidden="true">${ICON[name]||''}</svg>`; }

/* =====================================================================
   4. UTILITIES
   ===================================================================== */
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
function esc(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escRe(s){ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function uid(){ return 'n' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
function now(){ return Date.now(); }
function toast(msg){
  const t = $('#toast'); t.textContent = msg; t.classList.add('show');
  clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove('show'), 1600);
}
function fmtDate(ts){
  if (!ts) return '';
  const d = new Date(ts), n = new Date(), diff = n - d;
  if (diff < 60000) return 'Just now';
  if (diff < 3600000) return Math.floor(diff / 60000) + ' min ago';
  const sameDay = d.toDateString() === n.toDateString();
  if (sameDay) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
  const yest = new Date(n); yest.setDate(n.getDate() - 1);
  if (d.toDateString() === yest.toDateString()) return 'Yesterday';
  if (diff < 7 * 86400000) return d.toLocaleDateString([], { weekday: 'short' });
  return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: d.getFullYear() === n.getFullYear() ? undefined : 'numeric' });
}
/** Highlight every case-insensitive occurrence of q inside plain text; returns escaped HTML. */
function hl(text, q){
  text = String(text);
  if (!q) return esc(text);
  const re = new RegExp(escRe(q), 'gi');
  let out = '', last = 0, m;
  while ((m = re.exec(text))) {
    out += esc(text.slice(last, m.index)) + '<mark>' + esc(m[0]) + '</mark>';
    last = m.index + m[0].length;
    if (m[0].length === 0) break;
  }
  return out + esc(text.slice(last));
}

/* =====================================================================
   5. SANITIZER + TEXT EXTRACTION
   Only a small formatting subset survives. Everything else is unwrapped
   (keeping its text) or dropped entirely (script/style/etc).
   ===================================================================== */
const ALLOWED = new Set(['P','DIV','BR','B','I','U','S','CODE','H1','H2','H3','UL','OL','LI','BLOCKQUOTE','HR']);
const INLINE = new Set(['B','I','U','S','CODE','BR']);
const DROP = new Set(['SCRIPT','STYLE','TEMPLATE','IFRAME','OBJECT','EMBED','NOSCRIPT','SVG','MATH','HEAD','TITLE','META','LINK','TEXTAREA','SELECT','BUTTON','INPUT','IMG','VIDEO','AUDIO','CANVAS','FORM']);
const RENAME_TAG = { STRONG:'B', EM:'I', STRIKE:'S', DEL:'S' };
const BLOCK_TAGS = new Set(['P','DIV','H1','H2','H3','LI','UL','OL','BLOCKQUOTE','HR']);
const BLOCK_SELECTOR = 'li,h1,h2,h3,p,div,blockquote';

function sanitizeHTML(html){
  const tpl = document.createElement('template');
  tpl.innerHTML = String(html || '');
  cleanChildren(tpl.content);
  wrapLooseInline(tpl.content);
  const out = tpl.innerHTML.trim();
  return out || '<div><br></div>';
}
function cleanChildren(parent){
  for (const node of [...parent.childNodes]) {
    if (node.nodeType === 3) continue;
    if (node.nodeType !== 1) { node.remove(); continue; }
    let tag = node.tagName;
    if (DROP.has(tag)) { node.remove(); continue; }
    if (RENAME_TAG[tag]) {
      const n = document.createElement(RENAME_TAG[tag]);
      n.append(...node.childNodes);
      node.replaceWith(n); node = n; tag = n.tagName;
    }
    if (!ALLOWED.has(tag)) {
      cleanChildren(node);
      node.replaceWith(...node.childNodes);
      continue;
    }
    const isTodo = node.classList && node.classList.contains('todo');
    const done = node.getAttribute && node.getAttribute('data-done');
    for (const a of [...node.attributes]) node.removeAttribute(a.name);
    if ((tag === 'UL' || tag === 'OL') && isTodo) node.className = 'todo';
    if (tag === 'LI' && done !== null) node.setAttribute('data-done', done === 'true' ? 'true' : 'false');
    cleanChildren(node);
  }
}
/** Wrap bare inline runs at the root in <div> so every line is a block. */
function wrapLooseInline(root){
  let run = null;
  for (const n of [...root.childNodes]) {
    const isText = n.nodeType === 3;
    if (isText && !n.textContent.trim() && !run) { n.remove(); continue; }
    const inline = isText || (n.nodeType === 1 && INLINE.has(n.tagName));
    if (inline) {
      if (!run) { run = document.createElement('div'); n.before(run); }
      run.appendChild(n);
    } else run = null;
  }
}
/** Pure text extraction: no tag names leak into the result. */
function htmlToText(html){
  const tpl = document.createElement('template');
  tpl.innerHTML = String(html || '');
  let out = '';
  const walk = (node) => {
    for (const c of node.childNodes) {
      if (c.nodeType === 3) { out += c.textContent; continue; }
      if (c.nodeType !== 1) continue;
      if (c.tagName === 'BR') { out += '\n'; continue; }
      if (DROP.has(c.tagName)) continue;
      const block = BLOCK_TAGS.has(c.tagName);
      if (block) out += '\n';
      walk(c);
      if (block) out += '\n';
    }
  };
  walk(tpl.content);
  return out.replace(/[ \t\u00a0]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{2,}/g, '\n').trim();
}

/* =====================================================================
   6. DATA MODEL — single source of truth (serializable)
   db = { notebooks:[{id,name}], notes:[{id,notebookId,title,html,tags,
          pinned,trashedAt,trashedNotebookName,createdAt,updatedAt}],
          ui:{view,selectedNoteId,sort,sideCollapsed,sideW,listW,theme,expanded,query} }
   ===================================================================== */
let db = null;

function seedDB(){
  const nbs = CONFIG.DEFAULT_NOTEBOOK_NAMES.map((name, i) => ({ id: 'nb' + (i + 1), name }));
  const t = now();
  const welcome = {
    id: uid(), notebookId: 'nb1', title: 'Welcome to Notes',
    html: '<h2>Welcome</h2>' +
      '<div>Write freely. Try <b>bold</b>, <i>italic</i>, <u>underline</u>, <s>strike</s> or <code>inline code</code>.</div>' +
      '<br><ul class="todo"><li data-done="true">Explore the toolbar</li><li data-done="false">Type <code>[] </code> at a line start for a checklist</li></ul>' +
      '<blockquote><div>Markdown shortcuts: <code># </code> heading, <code>- </code> bullet, <code>&gt; </code> quote.</div></blockquote>' +
      '<div><br></div><div>Drag notes onto a notebook to move them. Deleted notes wait in the Trash.</div>',
    tags: ['welcome'], pinned: true, trashedAt: null, createdAt: t - 5000, updatedAt: t - 5000,
  };
  const second = {
    id: uid(), notebookId: 'nb2', title: 'Q3 planning', html: '<div>Kick-off agenda.</div><ul><li>Review roadmap</li><li>Hiring plan</li></ul>',
    tags: ['planning'], pinned: false, trashedAt: null, createdAt: t - 90000, updatedAt: t - 90000,
  };
  return {
    notebooks: nbs,
    notes: [welcome, second],
    ui: { view: { type: 'all' }, selectedNoteId: welcome.id, sort: 'modified', sideCollapsed: false,
          sideW: CONFIG.SIDE_DEFAULT, listW: CONFIG.LIST_DEFAULT, theme: 'light', expanded: { nb1: true }, query: '' },
  };
}

function loadDB(){
  let data = null;
  try { data = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEY) || 'null'); } catch (e) { data = null; }
  if (!data || !Array.isArray(data.notebooks) || !Array.isArray(data.notes)) return seedDB();
  data.ui = Object.assign({ view: { type: 'all' }, selectedNoteId: null, sort: 'modified', sideCollapsed: false,
    sideW: CONFIG.SIDE_DEFAULT, listW: CONFIG.LIST_DEFAULT, theme: 'light', expanded: {}, query: '' }, data.ui || {});
  data.notes.forEach(n => {
    n.html = sanitizeHTML(n.html);
    n.tags = Array.isArray(n.tags) ? n.tags : [];
    n.pinned = !!n.pinned;
    n.trashedAt = n.trashedAt || null;
  });
  return data;
}

let saveTimer = null;
function persist(){
  setSaveState('pending');
  clearTimeout(saveTimer);
  saveTimer = setTimeout(flushSave, CONFIG.AUTOSAVE_MS);
}
function flushSave(){
  if (!saveTimer && !$('#saveState').classList.contains('pending')) return;
  clearTimeout(saveTimer); saveTimer = null;
  try { localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(db)); setSaveState('saved'); }
  catch (e) { setSaveState('error'); }
}
function setSaveState(s){
  const el = $('#saveState');
  el.classList.toggle('pending', s === 'pending');
  $('#saveText').textContent = s === 'pending' ? 'Saving…' : s === 'error' ? 'Save failed' : 'Saved';
}

/* ----- model queries (pure) ----- */
const getNote = id => db.notes.find(n => n.id === id) || null;
const getNb = id => db.notebooks.find(n => n.id === id) || null;
const activeNotes = () => db.notes.filter(n => !n.trashedAt);
const trashedNotes = () => db.notes.filter(n => !!n.trashedAt);
function noteTitle(n){ return (n.title || '').trim() || htmlToText(n.html).split('\n')[0].slice(0, 80) || 'Untitled'; }
function noteBody(n){ return htmlToText(n.html); }
function noteSnippet(n, q){
  const text = noteBody(n).replace(/\s+/g, ' ');
  if (!q) return text.slice(0, CONFIG.SNIPPET_LEN);
  const idx = text.toLowerCase().indexOf(q.toLowerCase());
  if (idx < 0) return text.slice(0, CONFIG.SNIPPET_LEN);
  const start = Math.max(0, idx - 36);
  return (start > 0 ? '…' : '') + text.slice(start, start + CONFIG.SNIPPET_LEN);
}
function sortNotes(arr){
  const key = db.ui.sort;
  return arr.sort((a, b) => {
    if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1;
    if (key === 'alpha') return noteTitle(a).localeCompare(noteTitle(b), undefined, { sensitivity: 'base' });
    if (key === 'created') return b.createdAt - a.createdAt;
    return b.updatedAt - a.updatedAt;
  });
}
function allTags(){
  const m = new Map();
  activeNotes().forEach(n => n.tags.forEach(t => {
    const k = t.toLowerCase(); const e = m.get(k) || { name: t, count: 0 }; e.count++; m.set(k, e);
  }));
  return [...m.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function matchesQuery(n, q){
  if (!q) return true;
  const ql = q.toLowerCase();
  return noteTitle(n).toLowerCase().includes(ql) || noteBody(n).toLowerCase().includes(ql);
}
/** Notes visible in the middle pane for the current view + search. */
function visibleNotes(){
  const { view, query } = db.ui;
  const q = (query || '').trim();
  let list;
  if (q) list = activeNotes().filter(n => matchesQuery(n, q));
  else if (view.type === 'trash') list = trashedNotes();
  else if (view.type === 'nb') list = activeNotes().filter(n => n.notebookId === view.id);
  else if (view.type === 'tag') list = activeNotes().filter(n => n.tags.some(t => t.toLowerCase() === view.tag.toLowerCase()));
  else list = activeNotes();
  return sortNotes([...list]);
}

/* =====================================================================
   7. ICON-BUTTON STATIC LABELS (filled in at init)
   ===================================================================== */
function paintStaticIcons(){
  $('#sideToggle').innerHTML = ico('sidebar');
  $('#newNoteBtn').innerHTML = ico('plus');
  $('#searchClear').innerHTML = ico('x');
  $('#pinBtn').innerHTML = ico('pin');
  $('#moveBtn').innerHTML = ico('more');
  $('#dupBtn').innerHTML = ico('dup');
  $('#trashBtn').innerHTML = ico('trash');
  $('#emptyIcon').innerHTML = ico('note');
  $('#toolbar [data-act="ul"]').innerHTML = ico('bullet');
  $('#toolbar [data-act="ol"]').innerHTML = ico('ordered');
  $('#toolbar [data-act="todo"]').innerHTML = ico('todoIco');
  $('#toolbar [data-act="quote"]').innerHTML = ico('quote');
  $('#toolbar [data-act="hr"]').innerHTML = ico('hr');
  $('#toolbar [data-act="code"]').classList.add('code');
}

/* =====================================================================
   8. SIDEBAR — notebooks, tags, trash (projection of db)
   ===================================================================== */
function isViewActive(type, id){
  const v = db.ui.view;
  if (db.ui.query) return false;
  if (v.type !== type) return false;
  if (type === 'nb') return v.id === id;
  if (type === 'tag') return v.tag === id;
  return true;
}
function navItem(type, id, iconHtml, label, count, extra = ''){
  const active = isViewActive(type, id) ? ' active' : '';
  const attrs = type === 'nb' ? `data-view="nb" data-id="${id}"` : type === 'tag' ? `data-view="tag" data-tag="${esc(id)}"` : `data-view="${type}"`;
  return `<div class="nav-item${active}" ${attrs} ${extra}>${iconHtml}<span class="label">${label}</span>${count !== '' && count != null ? `<span class="count">${count}</span>` : ''}</div>`;
}
function renderSidebar(){
  const expanded = db.ui.expanded;
  const openNote = db.ui.selectedNoteId;
  let h = `<div class="side-head"><div class="brand"><span class="logo">${ico('note')}</span>Notes</div>
    <button class="icon-btn" data-act="toggle-side" title="Collapse sidebar">${ico('sidebar')}</button></div>
    <div class="side-scroll">`;
  h += navItem('all', null, ico('layers'), 'All Notes', activeNotes().length);
  h += `<div class="section-head">Notebooks
    <button class="icon-btn" style="width:22px;height:22px" data-act="new-nb" title="New notebook">${ico('plus')}</button></div>`;
  for (const nb of db.notebooks) {
    const notes = sortNotes(activeNotes().filter(n => n.notebookId === nb.id));
    const open = !!expanded[nb.id];
    h += `<div class="nb" data-nb="${nb.id}">
      <div class="nav-item${isViewActive('nb', nb.id) ? ' active' : ''}" data-view="nb" data-id="${nb.id}" title="Double-click to rename">
        <span class="chev${open ? ' open' : ''}" data-act="expand" data-id="${nb.id}">${ico('chev')}</span>
        ${ico('folder')}<span class="label" data-rename-nb="${nb.id}">${esc(nb.name)}</span>
        <span class="count">${notes.length}</span>
        <button class="icon-btn" style="width:22px;height:22px" data-act="nb-menu" data-id="${nb.id}" title="More">${ico('more')}</button>
      </div>`;
    if (open) {
      h += `<div class="nb-notes">` + (notes.length
        ? notes.map(n => `<button class="nb-note${n.id === openNote ? ' active' : ''}" data-open-note="${n.id}">${esc(noteTitle(n))}</button>`).join('')
        : `<div class="nb-note" style="cursor:default;opacity:.7">No notes yet</div>`) + `</div>`;
    }
    h += `</div>`;
  }
  const tags = allTags();
  if (tags.length) {
    h += `<div class="section-head">Tags</div>`;
    for (const t of tags) {
      h += `<div class="nav-item${isViewActive('tag', t.name) ? ' active' : ''}" data-view="tag" data-tag="${esc(t.name)}">
        <span class="tag-hash">#</span><span class="label">${esc(t.name)}</span><span class="count">${t.count}</span></div>`;
    }
  }
  h += `<div class="section-head" style="padding-top:14px"></div>`;
  h += navItem('trash', null, ico('trash'), 'Trash', trashedNotes().length || '');
  h += `</div>
    <div class="side-foot"><small>Saved locally</small>
      <button class="icon-btn" data-act="theme" title="Toggle dark theme">${ico(db.ui.theme === 'dark' ? 'sun' : 'moon')}</button></div>`;
  $('#sideInner').innerHTML = h;
  renderSidebarWidth();
}

/* =====================================================================
   9. NOTE LIST — middle pane
   ===================================================================== */
function viewTitle(){
  const q = db.ui.query.trim();
  if (q) return 'Search';
  const v = db.ui.view;
  if (v.type === 'nb') return getNb(v.id)?.name || 'Notebook';
  if (v.type === 'tag') return '#' + v.tag;
  if (v.type === 'trash') return 'Trash';
  return 'All Notes';
}
function noteItemHTML(n, q, opts){
  const sel = n.id === db.ui.selectedNoteId ? ' sel' : '';
  const tsLabel = db.ui.view.type === 'trash' && !q ? fmtDate(n.trashedAt)
    : db.ui.sort === 'created' ? fmtDate(n.createdAt) : fmtDate(n.updatedAt);
  const nb = getNb(n.notebookId);
  const showNb = !opts.inNb && nb;
  return `<div class="note-item${sel}" draggable="${n.trashedAt ? 'false' : 'true'}" data-note="${n.id}">
    <div class="ni-title">${hl(noteTitle(n), q)}</div>
    <div class="ni-snip">${hl(noteSnippet(n, q), q) || '&nbsp;'}</div>
    <div class="ni-meta"><span>${tsLabel}</span>${showNb ? `<span>·</span><span class="nbn">${esc(nb.name)}</span>` : ''}
      ${n.tags.slice(0, 3).map(t => `<span class="mini-tag">#${esc(t)}</span>`).join('')}</div>
    ${n.pinned && !n.trashedAt ? `<span class="ni-pin">${ico('pin')}</span>` : ''}
    ${n.trashedAt ? '' : `<button class="ni-more" data-act="list-menu" data-note="${n.id}" title="More">${ico('more')}</button>`}
  </div>`;
}
function renderList(){
  const q = db.ui.query.trim();
  const notes = visibleNotes();
  $('#listTitle').textContent = viewTitle();
  $('#listSub').textContent = notes.length + (notes.length === 1 ? ' note' : ' notes');
  $('#searchClear').hidden = !db.ui.query;
  $('#sortSel').value = db.ui.sort;
  $('#sortSel').disabled = db.ui.view.type === 'trash' && !q;
  const scroll = $('#listScroll');
  if (!notes.length) {
    scroll.innerHTML = `<div class="list-empty">${ico(q ? 'search' : 'note')}
      ${q ? `No notes match “${esc(q)}”` : db.ui.view.type === 'trash' ? 'Trash is empty' : 'No notes here yet'}</div>`;
    return;
  }
  const opts = { inNb: db.ui.view.type === 'nb' && !q };
  const pinned = notes.filter(n => n.pinned && !n.trashedAt);
  const rest = notes.filter(n => !(n.pinned && !n.trashedAt));
  let h = '';
  if (pinned.length && rest.length && !q) {
    h += `<div class="section-label-in">Pinned</div>` + pinned.map(n => noteItemHTML(n, q, opts)).join('');
    h += `<div class="section-label-in">Notes</div>` + rest.map(n => noteItemHTML(n, q, opts)).join('');
  } else {
    h = notes.map(n => noteItemHTML(n, q, opts)).join('');
  }
  scroll.innerHTML = h;
}

/* =====================================================================
   10. EDITOR PANE — projection of the selected note
   ===================================================================== */
const editorEl = () => $('#editor');
function currentNote(){ return getNote(db.ui.selectedNoteId); }

function renderEditor(){
  const n = currentNote();
  const pane = $('#editorPane');
  pane.classList.toggle('no-note', !n);
  $('#empty').classList.toggle('show', !n);
  if (!n) { $('#crumbs').textContent = ''; return; }
  const trashed = !!n.trashedAt;
  const ed = editorEl();
  const title = $('#edTitle');
  // Only rebuild the DOM when the note actually changed (keeps caret stable).
  let switched = false;
  if (ed.dataset.noteId !== n.id || ed.dataset.trashed !== String(trashed)) {
    switched = true;
    ed.dataset.noteId = n.id;
    ed.dataset.trashed = String(trashed);
    ed.innerHTML = n.html;
    title.value = n.title || '';
    H.reset();
  }
  title.readOnly = trashed;
  title.placeholder = 'Untitled';
  ed.contentEditable = trashed ? 'false' : 'true';
  $('#edWrap').classList.remove('anim'); void $('#edWrap').offsetWidth; $('#edWrap').classList.add('anim');
  $('#trashBanner').hidden = !trashed;
  $$('#toolbar .tb').forEach(b => b.disabled = trashed);
  $('#dupBtn').disabled = trashed;
  $('#pinBtn').disabled = trashed;
  $('#pinBtn').classList.toggle('on', n.pinned && !trashed);
  $('#pinBtn').innerHTML = ico('pin');
  $('#trashBtn').innerHTML = ico('trash');
  $('#trashBtn').title = trashed ? 'Delete permanently' : 'Move to Trash (Del)';
  $('#trashBtn').dataset.act = trashed ? 'purge' : 'trash';
  $('#moveBtn').disabled = trashed;
  const nb = getNb(n.notebookId);
  $('#crumbs').innerHTML = trashed ? 'Trash' : `${esc(nb ? nb.name : 'Notebook')} / <b>${esc(noteTitle(n))}</b>`;
  if (switched) renderTags();
  updatePlaceholder();
  updateToolbarState();
}
function renderTags(){
  const n = currentNote();
  const box = $('#edTags');
  if (!n) { box.innerHTML = ''; return; }
  const trashed = !!n.trashedAt;
  box.innerHTML = n.tags.map(t => `<span class="tag-chip" data-tag="${esc(t)}" title="Filter by #${esc(t)}">#${esc(t)}
      ${trashed ? '' : `<span class="x" data-act="rm-tag" data-tag="${esc(t)}" title="Remove tag">${ico('x')}</span>`}</span>`).join('')
    + (trashed ? '' : `<input class="tag-add" id="tagAdd" placeholder="+ Add tag" maxlength="32" spellcheck="false">`);
}
function updatePlaceholder(){
  const ed = editorEl();
  const empty = !ed.textContent.trim() && !ed.querySelector('hr,ul,ol,blockquote,h1,h2,h3');
  $('#ph').classList.toggle('show', !!empty && ed.contentEditable === 'true');
}

/* =====================================================================
   11. NOTE & NOTEBOOK ACTIONS — all mutations go through db, then render
   ===================================================================== */
function renderAll(){
  renderSidebar(); renderList(); renderEditor(); syncSearchBox();
}
let refreshTimer = null;
function scheduleRefresh(){
  clearTimeout(refreshTimer);
  refreshTimer = setTimeout(() => {
    renderSidebar(); renderList();
    const n = currentNote();
    if (n) $('#crumbs').innerHTML = `${esc(getNb(n.notebookId)?.name || 'Notebook')} / <b>${esc(noteTitle(n))}</b>`;
  }, CONFIG.LIST_REFRESH_MS);
}
function ensureSelectionVisible(){
  const list = visibleNotes();
  if (!list.find(n => n.id === db.ui.selectedNoteId)) db.ui.selectedNoteId = list.length ? list[0].id : null;
}
function selectNote(id){
  db.ui.selectedNoteId = id;
  renderAll(); persist();
  if (id) requestAnimationFrame(() => $('.note-item.sel')?.scrollIntoView({ block: 'nearest' }));
}
function setView(view){
  db.ui.view = view; db.ui.query = '';
  ensureSelectionVisible();
  renderAll(); persist();
}
function syncSearchBox(){
  const si = $('#searchInput');
  if (si.value !== db.ui.query) si.value = db.ui.query;
}
function firstNotebookId(){
  if (!db.notebooks.length) db.notebooks.push({ id: 'nb' + uid(), name: 'Notes' });
  return db.notebooks[0].id;
}
function createNote(){
  const nbId = db.ui.view.type === 'nb' ? db.ui.view.id : firstNotebookId();
  const t = now();
  const note = { id: uid(), notebookId: nbId, title: '', html: '<div><br></div>', tags: [], pinned: false,
    trashedAt: null, createdAt: t, updatedAt: t };
  db.notes.unshift(note);
  if (db.ui.view.type === 'trash' || db.ui.view.type === 'tag') db.ui.view = { type: 'all' };
  db.ui.query = '';
  db.ui.selectedNoteId = note.id;
  db.ui.expanded[nbId] = true;
  renderAll(); persist();
  $('#edTitle').focus();
}
function trashNote(id){
  const n = getNote(id); if (!n) return;
  n.trashedAt = now();
  if (db.ui.selectedNoteId === id) db.ui.selectedNoteId = null;
  ensureSelectionVisible();
  renderAll(); persist();
  toast('Moved to Trash');
}
function restoreNote(id){
  const n = getNote(id); if (!n) return;
  let nb = getNb(n.notebookId);
  if (!nb) {
    nb = { id: n.notebookId || 'nb' + uid(), name: n.trashedNotebookName || 'Restored' };
    n.notebookId = nb.id;
    db.notebooks.push(nb);
  }
  n.trashedAt = null; n.trashedNotebookName = undefined;
  db.ui.view = { type: 'nb', id: nb.id }; db.ui.query = ''; db.ui.expanded[nb.id] = true;
  db.ui.selectedNoteId = n.id;
  renderAll(); persist();
  toast('Restored to ' + nb.name);
}
function purgeNote(id){
  db.notes = db.notes.filter(n => n.id !== id);
  if (db.ui.selectedNoteId === id) db.ui.selectedNoteId = null;
  ensureSelectionVisible();
  renderAll(); persist(); toast('Deleted permanently');
}
function togglePin(id){
  const n = getNote(id); if (!n || n.trashedAt) return;
  n.pinned = !n.pinned; n.updatedAt = n.updatedAt; // pin changes do not bump modified date
  renderAll(); persist(); toast(n.pinned ? 'Pinned' : 'Unpinned');
}
function duplicateNote(id){
  const n = getNote(id); if (!n) return;
  const t = now();
  const copy = Object.assign({}, n, { id: uid(), title: noteTitle(n) + ' copy', pinned: false, tags: [...n.tags], createdAt: t, updatedAt: t });
  const idx = db.notes.indexOf(n);
  db.notes.splice(idx, 0, copy);
  db.ui.selectedNoteId = copy.id;
  renderAll(); persist(); toast('Duplicated');
}
function moveNote(id, nbId){
  const n = getNote(id); const nb = getNb(nbId); if (!n || !nb || n.notebookId === nbId) return;
  n.notebookId = nbId; n.updatedAt = now();
  renderAll(); persist(); toast('Moved to ' + nb.name);
}
function addTag(id, raw){
  const n = getNote(id); if (!n) return;
  const name = raw.replace(/^#+/, '').trim().slice(0, 32);
  if (!name) return;
  if (!n.tags.some(t => t.toLowerCase() === name.toLowerCase())) n.tags.push(name);
  n.updatedAt = now();
  renderAll(); persist();
}
function removeTag(id, tag){
  const n = getNote(id); if (!n) return;
  n.tags = n.tags.filter(t => t !== tag);
  n.updatedAt = now();
  renderAll(); persist();
}
function createNotebook(){
  const nb = { id: 'nb' + uid(), name: 'Untitled notebook' };
  db.notebooks.push(nb);
  db.ui.expanded[nb.id] = true;
  db.ui.view = { type: 'nb', id: nb.id }; db.ui.query = ''; db.ui.selectedNoteId = null;
  renderAll(); persist();
  startRenameNotebook(nb.id);
}
function deleteNotebook(id){
  const nb = getNb(id); if (!nb) return;
  db.notes.forEach(n => { if (n.notebookId === id && !n.trashedAt) { n.trashedAt = now(); n.trashedNotebookName = nb.name; } });
  db.notebooks = db.notebooks.filter(x => x.id !== id);
  if (db.ui.view.type === 'nb' && db.ui.view.id === id) db.ui.view = { type: 'all' };
  ensureSelectionVisible();
  renderAll(); persist(); toast('Notebook deleted — notes moved to Trash');
}
function startRenameNotebook(id){
  const nb = getNb(id); if (!nb) return;
  const span = $(`[data-rename-nb="${id}"]`); if (!span) return;
  const input = document.createElement('input');
  input.className = 'rename'; input.value = nb.name; input.maxLength = 48;
  span.replaceWith(input);
  input.focus(); input.select();
  let done = false;
  const finish = (commit) => {
    if (done) return; done = true;
    if (commit) { const v = input.value.trim(); if (v) nb.name = v; }
    renderAll(); persist();
  };
  input.addEventListener('keydown', e => {
    if (e.key === 'Enter') { e.preventDefault(); finish(true); }
    if (e.key === 'Escape') { e.preventDefault(); finish(false); }
    e.stopPropagation();
  });
  input.addEventListener('click', e => e.stopPropagation());
  input.addEventListener('blur', () => finish(true));
}

/* ----- title / body sync handlers ----- */
function onTitleInput(){
  const n = currentNote(); if (!n || n.trashedAt) return;
  n.title = $('#edTitle').value; n.updatedAt = now();
  persist(); scheduleRefresh();
}

/* =====================================================================
   12. CONTEXT MENUS
   ===================================================================== */
let menuEl = null;
function closeMenu(){ if (menuEl) { menuEl.remove(); menuEl = null; } }
/** items: {label, icon, fn, danger, check} | {sep:true} | {head:'text'} */
function openMenu(x, y, items){
  closeMenu();
  const m = document.createElement('div');
  m.className = 'menu'; m.setAttribute('role', 'menu');
  m.innerHTML = items.map((it, i) => {
    if (it.sep) return '<div class="menu-sep"></div>';
    if (it.head) return `<div class="menu-head">${esc(it.head)}</div>`;
    return `<button class="menu-item${it.danger ? ' danger' : ''}" data-i="${i}" role="menuitem">${it.icon ? ico(it.icon) : ''}<span>${esc(it.label)}</span>${it.check ? ico('check', 'ck') : ''}</button>`;
  }).join('');
  document.body.appendChild(m);
  const r = m.getBoundingClientRect();
  m.style.left = Math.min(x, innerWidth - r.width - 8) + 'px';
  m.style.top = Math.min(y, innerHeight - r.height - 8) + 'px';
  m.addEventListener('click', e => {
    const b = e.target.closest('.menu-item'); if (!b) return;
    const it = items[+b.dataset.i];
    closeMenu();
    it.fn && it.fn();
  });
  menuEl = m;
}
function noteMenu(id, x, y){
  const n = getNote(id); if (!n) return;
  const items = [
    { head: 'Move to notebook' },
    ...db.notebooks.map(nb => ({ label: nb.name, icon: 'folder', check: nb.id === n.notebookId, fn: () => moveNote(id, nb.id) })),
    { sep: true },
    { label: n.pinned ? 'Unpin' : 'Pin to top', icon: 'pin', fn: () => togglePin(id) },
    { label: 'Duplicate', icon: 'dup', fn: () => duplicateNote(id) },
    { sep: true },
    { label: 'Move to Trash', icon: 'trash', danger: true, fn: () => trashNote(id) },
  ];
  openMenu(x, y, items);
}
function notebookMenu(id, x, y){
  openMenu(x, y, [
    { label: 'Rename', icon: 'edit', fn: () => startRenameNotebook(id) },
    { label: 'New note here', icon: 'plus', fn: () => { db.ui.view = { type: 'nb', id }; db.ui.query = ''; createNote(); } },
    { sep: true },
    { label: 'Delete notebook', icon: 'trash', danger: true, fn: () => deleteNotebook(id) },
  ]);
}

/* =====================================================================
   13. EDITOR — commands, markdown shortcuts, lists, paste, history
   ===================================================================== */
const ed = () => $('#editor');
function isEditorFocus(){ const a = document.activeElement; return a === ed() || (ed().contains(document.getSelection().anchorNode)); }
function sel(){ return window.getSelection(); }
function curRange(){
  const s = sel();
  if (!s.rangeCount) return null;
  const r = s.getRangeAt(0);
  return ed().contains(r.startContainer) ? r : null;
}
function isEmptyLine(el){
  return !el.textContent.replace(/\u200b/g, '').trim() && !el.querySelector('img,hr');
}
function ensureBr(el){
  if (isEmptyLine(el) && !el.querySelector('br')) el.appendChild(document.createElement('br'));
  return el;
}
function placeCaret(el, atEnd = true){
  const r = document.createRange();
  if (atEnd) { r.selectNodeContents(el); r.collapse(false); }
  else { r.setStart(el, 0); r.collapse(true); }
  const s = sel(); s.removeAllRanges(); s.addRange(r);
}
/** Find the editable "line": a top-level block, or an LI, or a line inside a blockquote. */
function currentLine(){
  const r = curRange(); if (!r) return null;
  let el = r.startContainer.nodeType === 1 ? r.startContainer : r.startContainer.parentNode;
  if (el === ed()) {
    // caret directly on root: wrap loose content into a line
    const d = document.createElement('div');
    if (!ed().children.length) { ed().appendChild(d); ensureBr(d); placeCaret(d); return d; }
    const child = ed().childNodes[Math.min(r.startOffset, ed().childNodes.length - 1)];
    const line = child.nodeType === 1 && /^(DIV|P|H[1-3]|UL|OL|BLOCKQUOTE|HR)$/.test(child.tagName) ? child : null;
    if (line && line.tagName !== 'UL' && line.tagName !== 'OL' && line.tagName !== 'BLOCKQUOTE') return line;
    return null;
  }
  const li = el.closest ? el.closest('li') : null;
  if (li && ed().contains(li)) return li;
  let blk = el;
  while (blk.parentNode && blk.parentNode !== ed() && blk.parentNode.tagName !== 'BLOCKQUOTE') blk = blk.parentNode;
  if (blk.tagName === 'BLOCKQUOTE') {
    // caret sits directly in blockquote: make the inline run a div
    const d = document.createElement('div');
    while (blk.firstChild && !(blk.firstChild.nodeType === 1 && blk.firstChild.tagName === 'DIV')) d.appendChild(blk.firstChild);
    if (d.childNodes.length) blk.insertBefore(d, blk.firstChild);
    return d.childNodes.length ? d : blk.firstElementChild;
  }
  if (blk.parentNode === ed() || (blk.parentNode && blk.parentNode.tagName === 'BLOCKQUOTE')) return blk;
  return null;
}
function lineKind(line){
  if (line.tagName === 'LI') {
    const list = line.parentNode;
    return list.classList.contains('todo') ? 'todo' : list.tagName === 'OL' ? 'ol' : 'ul';
  }
  if (/^H[1-3]$/.test(line.tagName)) return line.tagName.toLowerCase();
  if (line.parentNode && line.parentNode.tagName === 'BLOCKQUOTE') return 'quote';
  return 'p';
}
/** Split a container around `child`, replacing the child slot with `repl`. */
function splitAround(container, child, repl){
  const before = container.cloneNode(false), after = container.cloneNode(false);
  let side = before;
  for (const c of [...container.childNodes]) {
    if (c === child) { side = after; continue; }
    side.appendChild(c);
  }
  const out = [];
  if (before.childNodes.length) out.push(before);
  out.push(repl);
  if (after.childNodes.length) out.push(after);
  container.replaceWith(...out);
}
/** Take a line out of any list / blockquote, returning a plain top-level block. */
function liftLine(line){
  if (line.tagName === 'LI') {
    const list = line.parentNode;
    const d = document.createElement('div');
    d.append(...line.childNodes);
    splitAround(list, line, d);
    return d;
  }
  if (line.parentNode && line.parentNode.tagName === 'BLOCKQUOTE') {
    const bq = line.parentNode;
    splitAround(bq, line, line);
    return line;
  }
  return line;
}
function retag(el, tag){
  const n = document.createElement(tag);
  n.append(...el.childNodes);
  el.replaceWith(n);
  return n;
}
/** Apply a block type to the caret line. force=true skips toggle-off. */
function setBlock(type, force = false){
  const line = currentLine(); if (!line) return;
  const cur = lineKind(line);
  if (!force && cur === type) type = 'p';
  const d = liftLine(line);
  let out = d;
  if (type === 'p') {
    out = d.tagName === 'DIV' ? d : retag(d, 'DIV');
  } else if (type === 'h1' || type === 'h2' || type === 'h3') {
    out = retag(d, type.toUpperCase());
  } else if (type === 'quote') {
    const d2 = d.tagName === 'DIV' ? d : retag(d, 'DIV');
    const prev = d2.previousElementSibling;
    if (prev && prev.tagName === 'BLOCKQUOTE') { prev.appendChild(d2); out = d2; }
    else { const bq = document.createElement('blockquote'); d2.replaceWith(bq); bq.appendChild(d2); out = d2; }
  } else if (type === 'ul' || type === 'ol' || type === 'todo') {
    const listTag = type === 'ol' ? 'OL' : 'UL';
    const li = document.createElement('li');
    li.append(...(d.tagName === 'DIV' ? d.childNodes : [...d.childNodes]));
    if (type === 'todo') li.setAttribute('data-done', 'false');
    const isMatch = (l) => l && l.tagName === listTag && (type === 'todo') === l.classList.contains('todo');
    const prev = d.previousElementSibling;
    let list;
    if (isMatch(prev)) { prev.appendChild(li); list = prev; }
    else {
      list = document.createElement(listTag);
      if (type === 'todo') list.className = 'todo';
      list.appendChild(li);
      d.replaceWith(list);
    }
    if (d.parentNode) d.remove();
    const next = list.nextElementSibling;
    if (isMatch(next)) { while (next.firstChild) list.appendChild(next.firstChild); next.remove(); }
    out = li;
  }
  ensureBr(out);
  placeCaret(out, true);
  if (out.tagName === 'LI' || out.tagName === 'DIV' || /^H/.test(out.tagName)) { /* ok */ }
  return out;
}
function insertHR(){
  const line = currentLine(); if (!line) return;
  const d = liftLine(line);
  const hr = document.createElement('hr');
  const after = document.createElement('div'); after.appendChild(document.createElement('br'));
  d.after(hr); hr.after(after);
  if (isEmptyLine(d)) d.remove();
  placeCaret(after, false);
}
function toggleInlineCode(){
  const r = curRange(); if (!r) return;
  const ancestor = (r.commonAncestorContainer.nodeType === 1 ? r.commonAncestorContainer : r.commonAncestorContainer.parentNode).closest('code');
  if (ancestor && ed().contains(ancestor)) {
    const frag = document.createDocumentFragment();
    frag.append(...ancestor.childNodes);
    ancestor.replaceWith(frag);
    return;
  }
  if (r.collapsed) return;
  const code = document.createElement('code');
  code.appendChild(r.extractContents());
  r.insertNode(code);
  const nr = document.createRange(); nr.selectNodeContents(code);
  const s = sel(); s.removeAllRanges(); s.addRange(nr);
}
function inlineFormatActive(name){
  const r = curRange(); if (!r) return false;
  const node = r.commonAncestorContainer.nodeType === 1 ? r.commonAncestorContainer : r.commonAncestorContainer.parentNode;
  if (name === 'code') return !!(node && node.closest && node.closest('code'));
  if (name === 'bold') return !!(node && node.closest && node.closest('b,strong'));
  if (name === 'italic') return !!(node && node.closest && node.closest('i,em'));
  if (name === 'underline') return !!(node && node.closest && node.closest('u'));
  if (name === 'strike') return !!(node && node.closest && node.closest('s,strike'));
  return false;
}
function execFmt(cmd){
  H.before(true);
  if (cmd === 'bold') document.execCommand('bold');
  else if (cmd === 'italic') document.execCommand('italic');
  else if (cmd === 'underline') document.execCommand('underline');
  else if (cmd === 'strike') document.execCommand('strikeThrough');
  H.after(); onEditorChanged(); updateToolbarState();
}
/** Command dispatcher for toolbar + shortcuts. */
function runCommand(act){
  const n = currentNote(); if (!n || n.trashedAt) return;
  ed().focus();
  const blockOps = { h1: 'h1', h2: 'h2', h3: 'h3', ul: 'ul', ol: 'ol', todo: 'todo', quote: 'quote' };
  if (['bold', 'italic', 'underline', 'strike'].includes(act)) return execFmt(act);
  H.before(true);
  if (act === 'code') toggleInlineCode();
  else if (blockOps[act]) setBlock(blockOps[act]);
  else if (act === 'hr') insertHR();
  H.after(); onEditorChanged(); updateToolbarState();
}

/* ----- Markdown-style input shortcuts ----- */
const MD = { '#': 'h1', '##': 'h2', '###': 'h3', '-': 'ul', '*': 'ul', '1.': 'ol', '[]': 'todo', '[ ]': 'todo', '>': 'quote' };
function tryMarkdown(e){
  const r = curRange(); if (!r || !r.collapsed) return false;
  const line = currentLine(); if (!line) return false;
  if (line.parentNode !== ed() || line.tagName === 'LI') return false;   // only at top-level line start
  if (!/^(DIV|P)$/.test(line.tagName)) return false;
  const pre = document.createRange();
  pre.setStart(line, 0);
  pre.setEnd(r.startContainer, r.startOffset);
  const token = pre.toString().replace(/\u00a0/g, ' ');
  const type = MD[token];
  if (!type) return false;
  e.preventDefault();
  H.before(true);
  pre.deleteContents();
  const s = sel(); s.removeAllRanges(); s.addRange(pre);
  setBlock(type, true);
  H.after(); onEditorChanged(); updateToolbarState();
  return true;
}

/* ----- Enter key: list continuation / exit ----- */
function handleEnter(e){
  if (e.shiftKey) return false;
  const line = currentLine(); if (!line) return false;
  if (line.tagName === 'LI') {
    e.preventDefault();
    H.before(true);
    if (isEmptyLine(line)) {
      const d = liftLine(line);
      ensureBr(d); placeCaret(d, true);
    } else {
      const r = curRange();
      const tail = document.createRange();
      tail.setStart(r.startContainer, r.startOffset);
      tail.setEnd(line, line.childNodes.length);
      const frag = tail.extractContents();
      const next = document.createElement('li');
      next.appendChild(frag);
      if (line.parentNode.classList.contains('todo')) next.setAttribute('data-done', 'false');
      ensureBr(next);
      line.after(next);
      placeCaret(next, false);
    }
    H.after(); onEditorChanged(); updateToolbarState();
    return true;
  }
  if (line.parentNode && line.parentNode.tagName === 'BLOCKQUOTE' && isEmptyLine(line)) {
    e.preventDefault();
    H.before(true);
    const d = liftLine(line); ensureBr(d); placeCaret(d, true);
    H.after(); onEditorChanged(); updateToolbarState();
    return true;
  }
  return false;
}

/* ----- Todo checkbox click ----- */
function onEditorClick(e){
  const li = e.target.closest && e.target.closest('ul.todo > li');
  if (!li) return;
  const rect = li.getBoundingClientRect();
  if (e.clientX - rect.left > 30) return;
  H.before(true);
  li.setAttribute('data-done', li.getAttribute('data-done') === 'true' ? 'false' : 'true');
  H.after(); onEditorChanged();
}

/* ----- Paste: sanitize to supported subset ----- */
function onPaste(e){
  e.preventDefault();
  const html = e.clipboardData.getData('text/html');
  const text = e.clipboardData.getData('text/plain');
  H.before(true);
  if (html && html.trim()) {
    const clean = sanitizeHTML(html).replace(/^<div><br><\/div>$/, '');
    if (clean) document.execCommand('insertHTML', false, clean);
  } else if (text) {
    if (text.indexOf('\n') < 0) document.execCommand('insertText', false, text);
    else {
      const lines = text.replace(/\r/g, '').split('\n');
      document.execCommand('insertHTML', false, lines.map(l => `<div>${esc(l) || '<br>'}</div>`).join(''));
    }
  }
  H.after(); onEditorChanged(); updateToolbarState();
}

/* ----- Toolbar state reflects caret formatting ----- */
function updateToolbarState(){
  const n = currentNote();
  const tb = $$('#toolbar .tb');
  const off = !n || n.trashedAt || !isEditorFocus() && !curRange();
  if (off) { tb.forEach(b => b.classList.remove('on')); return; }
  const line = currentLine();
  const kind = line ? lineKind(line) : 'p';
  const map = {
    bold: inlineFormatActive('bold'), italic: inlineFormatActive('italic'), underline: inlineFormatActive('underline'),
    strike: inlineFormatActive('strike'), code: inlineFormatActive('code'),
    h1: kind === 'h1', h2: kind === 'h2', h3: kind === 'h3', ul: kind === 'ul', ol: kind === 'ol',
    todo: kind === 'todo', quote: kind === 'quote',
  };
  tb.forEach(b => b.classList.toggle('on', !!map[b.dataset.fmt]));
}

/* =====================================================================
   14. UNDO / REDO — snapshot history (HTML + caret path)
   Typing bursts coalesce; every structural action is its own step.
   ===================================================================== */
function caretPath(){
  const r = curRange(); if (!r) return null;
  const path = []; let node = r.startContainer;
  while (node !== ed()) {
    const p = node.parentNode; if (!p) return null;
    path.unshift([...p.childNodes].indexOf(node));
    node = p;
  }
  return { p: path, o: r.startOffset };
}
function restoreCaret(c){
  const root = ed();
  let node = root;
  if (c) {
    for (const i of c.p) { if (!node.childNodes[i]) { node = null; break; } node = node.childNodes[i]; }
  } else node = null;
  const r = document.createRange();
  if (node) {
    const max = node.nodeType === 3 ? node.length : node.childNodes.length;
    r.setStart(node, Math.min(c.o, max)); r.collapse(true);
  } else { r.selectNodeContents(root); r.collapse(false); }
  const s = sel(); s.removeAllRanges(); s.addRange(r);
}
function snap(){ return { html: ed().innerHTML, c: caretPath() }; }
const H = {
  undo: [], redo: [], cur: null, t: 0,
  reset(){ this.undo = []; this.redo = []; this.cur = snap(); this.t = 0; },
  /** Call before a mutation. force=false coalesces rapid typing. */
  before(force){
    if (!this.cur) this.cur = snap();
    const idle = Date.now() - this.t > CONFIG.HISTORY_IDLE_MS;
    if (force || idle) {
      const top = this.undo[this.undo.length - 1];
      if (!top || top.html !== this.cur.html) {
        this.undo.push(this.cur);
        if (this.undo.length > CONFIG.HISTORY_LIMIT) this.undo.shift();
      }
    }
  },
  /** Call after a mutation. */
  after(){
    const s = snap();
    if (this.cur && s.html !== this.cur.html) this.redo = [];
    this.cur = s; this.t = Date.now();
  },
  apply(s){
    ed().innerHTML = s.html;
    restoreCaret(s.c);
    onEditorChanged(); updateToolbarState();
  },
  undoStep(){
    if (!this.undo.length) return toast('Nothing to undo');
    this.redo.push(this.cur || snap());
    const prev = this.undo.pop();
    this.cur = prev; this.t = 0;
    this.apply(prev);
  },
  redoStep(){
    if (!this.redo.length) return toast('Nothing to redo');
    this.undo.push(this.cur || snap());
    const next = this.redo.pop();
    this.cur = next; this.t = 0;
    this.apply(next);
  },
};

/* ----- sync DOM → model (the only write path from editor) ----- */
function onEditorChanged(){
  const n = currentNote();
  if (!n || n.trashedAt) return;
  n.html = sanitizeHTML(ed().innerHTML);
  n.updatedAt = now();
  updatePlaceholder();
  persist(); scheduleRefresh();
}
function onEditorInput(){ onEditorChanged(); }

/* ----- editor keyboard ----- */
function onEditorKeydown(e){
  const mod = e.ctrlKey || e.metaKey;
  const k = e.key.toLowerCase();
  if (mod && !e.altKey) {
    if (k === 'z' && !e.shiftKey) { e.preventDefault(); H.undoStep(); return; }
    if ((k === 'z' && e.shiftKey) || k === 'y') { e.preventDefault(); H.redoStep(); return; }
    if (k === 'b') { e.preventDefault(); runCommand('bold'); return; }
    if (k === 'i') { e.preventDefault(); runCommand('italic'); return; }
    if (k === 'u') { e.preventDefault(); runCommand('underline'); return; }
    if (k === 'e') { e.preventDefault(); runCommand('code'); return; }
    if (e.shiftKey && k === 's') { e.preventDefault(); runCommand('strike'); return; }
    if (e.shiftKey && k === '8') { e.preventDefault(); runCommand('ul'); return; }
    if (e.shiftKey && k === '7') { e.preventDefault(); runCommand('ol'); return; }
    if (e.shiftKey && k === '9') { e.preventDefault(); runCommand('todo'); return; }
    return;
  }
  if (e.key === 'Enter') { if (handleEnter(e)) return; }
  if (e.key === ' ' && !e.isComposing) { if (tryMarkdown(e)) return; }
  if (e.key === 'Tab' && !e.shiftKey) {
    const line = currentLine();
    if (line && line.tagName === 'LI') {
      e.preventDefault();
      const prev = line.previousElementSibling;
      if (prev && prev.tagName === 'LI') {
        H.before(true);
        let sub = prev.querySelector(':scope > ul, :scope > ol');
        if (!sub) { sub = document.createElement(line.parentNode.tagName); sub.className = line.parentNode.className; prev.appendChild(sub); }
        sub.appendChild(line);
        H.after(); onEditorChanged();
      }
    }
  }
}

/* =====================================================================
   15. SIDEBAR + LAYOUT — collapse, resize, theme
   ===================================================================== */
function renderSidebarWidth(){
  const app = $('#app');
  app.style.setProperty('--w-side', db.ui.sideW + 'px');
  app.style.setProperty('--w-list', db.ui.listW + 'px');
  app.classList.toggle('side-collapsed', !!db.ui.sideCollapsed);
}
function applyTheme(){
  document.documentElement.dataset.theme = db.ui.theme === 'dark' ? 'dark' : 'light';
}
function toggleSidebar(){
  db.ui.sideCollapsed = !db.ui.sideCollapsed;
  renderSidebarWidth(); persist();
}
function toggleTheme(){
  db.ui.theme = db.ui.theme === 'dark' ? 'light' : 'dark';
  applyTheme(); renderSidebar(); persist();
}
function setupResizers(){
  const bind = (handle, key, min, max, sign) => {
    handle.addEventListener('pointerdown', e => {
      if (key === 'sideW' && db.ui.sideCollapsed) return;
      e.preventDefault();
      handle.setPointerCapture(e.pointerId);
      handle.classList.add('drag');
      const startX = e.clientX, startW = db.ui[key];
      const move = ev => {
        const w = Math.max(min, Math.min(max, startW + sign * (ev.clientX - startX)));
        db.ui[key] = Math.round(w);
        renderSidebarWidth();
      };
      const up = () => {
        handle.classList.remove('drag');
        handle.removeEventListener('pointermove', move);
        handle.removeEventListener('pointerup', up);
        persist();
      };
      handle.addEventListener('pointermove', move);
      handle.addEventListener('pointerup', up);
    });
  };
  bind($('#hSide'), 'sideW', CONFIG.SIDE_MIN, CONFIG.SIDE_MAX, 1);
  bind($('#hList'), 'listW', CONFIG.LIST_MIN, CONFIG.LIST_MAX, 1);
}

/* =====================================================================
   16. EVENT WIRING — delegation for all interactive regions
   ===================================================================== */
function onSidebarClick(e){
  const act = e.target.closest('[data-act]');
  if (act) {
    const a = act.dataset.act;
    if (a === 'toggle-side') return toggleSidebar();
    if (a === 'theme') return toggleTheme();
    if (a === 'new-nb') return createNotebook();
    if (a === 'expand') {
      e.stopPropagation();
      db.ui.expanded[act.dataset.id] = !db.ui.expanded[act.dataset.id];
      renderSidebar(); persist(); return;
    }
    if (a === 'nb-menu') {
      e.stopPropagation();
      const r = act.getBoundingClientRect();
      return notebookMenu(act.dataset.id, r.right - 150, r.bottom + 4);
    }
  }
  const note = e.target.closest('[data-open-note]');
  if (note) {
    const n = getNote(note.dataset.openNote);
    if (n) {
      db.ui.view = { type: 'nb', id: n.notebookId }; db.ui.query = ''; db.ui.expanded[n.notebookId] = true;
      db.ui.selectedNoteId = n.id;
      renderAll(); persist();
    }
    return;
  }
  const nav = e.target.closest('[data-view]');
  if (nav) {
    const v = nav.dataset.view;
    if (v === 'all') setView({ type: 'all' });
    else if (v === 'trash') setView({ type: 'trash' });
    else if (v === 'nb') {
      const id = nav.dataset.id;
      const expanded = { ...db.ui.expanded };
      setView({ type: 'nb', id }); db.ui.expanded = expanded;
      db.ui.expanded[id] = !expanded[id] || db.ui.view.id !== id ? true : db.ui.expanded[id];
      renderAll(); persist();
    }
    else if (v === 'tag') setView({ type: 'tag', tag: nav.dataset.tag });
  }
}
function onSidebarDblClick(e){
  const lab = e.target.closest('[data-rename-nb]');
  if (lab) startRenameNotebook(lab.dataset.renameNb);
}
function onSidebarDragOver(e){
  const nb = e.target.closest('.nb');
  $$('.nb.drop').forEach(x => x !== nb && x.classList.remove('drop'));
  if (!nb || !dragState.id) return;
  e.preventDefault(); e.dataTransfer.dropEffect = 'move';
  nb.classList.add('drop');
}
function onSidebarDrop(e){
  const nb = e.target.closest('.nb');
  $$('.nb.drop').forEach(x => x.classList.remove('drop'));
  if (!nb || !dragState.id) return;
  e.preventDefault();
  moveNote(dragState.id, nb.dataset.nb);
}
function onSidebarDragLeave(e){
  const nb = e.target.closest && e.target.closest('.nb');
  if (nb && !nb.contains(e.relatedTarget)) nb.classList.remove('drop');
}
const dragState = { id: null };

function onListClick(e){
  const more = e.target.closest('[data-act="list-menu"]');
  if (more) {
    e.stopPropagation();
    const r = more.getBoundingClientRect();
    return noteMenu(more.dataset.note, r.right - 200, r.bottom + 4);
  }
  const item = e.target.closest('[data-note]');
  if (item) selectNote(item.dataset.note);
}
function onListContext(e){
  const item = e.target.closest('[data-note]');
  if (!item || getNote(item.dataset.note)?.trashedAt) return;
  e.preventDefault();
  noteMenu(item.dataset.note, e.clientX, e.clientY);
}
function onListDragStart(e){
  const item = e.target.closest('.note-item[data-note]');
  if (!item) return;
  dragState.id = item.dataset.note;
  item.classList.add('dragging');
  e.dataTransfer.effectAllowed = 'move';
  e.dataTransfer.setData('text/plain', dragState.id);
}
function onListDragEnd(){
  dragState.id = null;
  $$('.note-item.dragging').forEach(x => x.classList.remove('dragging'));
  $$('.nb.drop').forEach(x => x.classList.remove('drop'));
}

function onTagsClick(e){
  const x = e.target.closest('[data-act="rm-tag"]');
  if (x) { e.stopPropagation(); return removeTag(db.ui.selectedNoteId, x.dataset.tag); }
  const chip = e.target.closest('.tag-chip');
  if (chip) setView({ type: 'tag', tag: chip.dataset.tag });
}
function onTagsKeydown(e){
  if (e.target.id !== 'tagAdd') return;
  if (e.key === 'Enter' || e.key === ',') {
    e.preventDefault();
    const v = e.target.value; e.target.value = '';
    addTag(db.ui.selectedNoteId, v.replace(/,/g, ''));
    const again = $('#tagAdd'); if (again) again.focus();
  } else if (e.key === 'Escape') { e.target.value = ''; e.target.blur(); }
}

function onEditorToolbarClick(e){
  const b = e.target.closest('.tb'); if (!b || b.disabled) return;
  runCommand(b.dataset.act);
}
function onTitleKeydown(e){
  if (e.key === 'Enter') { e.preventDefault(); ed().focus(); const first = ed().firstChild; if (first) placeCaret(first, false); else placeCaret(ed(), false); }
}

/* ----- search ----- */
let searchTimer = null;
function onSearchInput(){
  db.ui.query = $('#searchInput').value;
  clearTimeout(searchTimer);
  searchTimer = setTimeout(() => {
    ensureSelectionVisible();
    renderAll(); persist();
  }, 90);
}
function clearSearch(){
  $('#searchInput').value = ''; db.ui.query = '';
  ensureSelectionVisible(); renderAll(); persist();
  $('#searchInput').focus();
}

/* ----- header / toolbar actions ----- */
function onHeaderClick(e){
  const act = e.target.closest('[data-act]'); if (!act) return;
  const a = act.dataset.act;
  const id = db.ui.selectedNoteId;
  if (a === 'toggle-side') return toggleSidebar();
  if (a === 'new-note') return createNote();
  if (a === 'clear-search') return clearSearch();
  if (a === 'pin') return togglePin(id);
  if (a === 'dup') return duplicateNote(id);
  if (a === 'trash') return trashNote(id);
  if (a === 'purge') return purgeNote(id);
  if (a === 'restore') return restoreNote(id);
  if (a === 'note-menu') { const r = act.getBoundingClientRect(); return noteMenu(id, r.right - 200, r.bottom + 4); }
}

function onGlobalKey(e){
  const mod = e.ctrlKey || e.metaKey;
  if (mod && (e.key === 'n' || e.key === 'N')) { e.preventDefault(); createNote(); }
  else if (mod && e.key === '\\') { e.preventDefault(); toggleSidebar(); }
  else if (mod && (e.key === 'k' || e.key === 'f') && !(document.activeElement === ed())) { e.preventDefault(); $('#searchInput').focus(); $('#searchInput').select(); }
  else if (e.key === 'Escape' && menuEl) closeMenu();
  else if (e.key === 'Escape' && document.activeElement === $('#searchInput')) { if ($('#searchInput').value) clearSearch(); else $('#searchInput').blur(); }
}

/* ----- selection changes drive toolbar state ----- */
let selRaf = 0;
function onSelectionChange(){
  if (selRaf) return;
  selRaf = requestAnimationFrame(() => { selRaf = 0; updateToolbarState(); });
}

/* ----- editor input / keys (history-aware) ----- */
function onEditorInputEvent(){
  H.before(false);
  onEditorChanged();
  H.after();
  updateToolbarState();
}

/* =====================================================================
   17. INIT
   ===================================================================== */
function init(){
  db = loadDB();
  if (!db.ui.view) db.ui.view = { type: 'all' };
  if (db.ui.selectedNoteId && !getNote(db.ui.selectedNoteId)) db.ui.selectedNoteId = null;
  paintStaticIcons();
  applyTheme();
  $('#sortSel').value = db.ui.sort;
  renderAll();
  setupResizers();

  $('#sideInner').addEventListener('click', onSidebarClick);
  $('#sideInner').addEventListener('dblclick', onSidebarDblClick);
  $('#sideInner').addEventListener('dragover', onSidebarDragOver);
  $('#sideInner').addEventListener('dragleave', onSidebarDragLeave);
  $('#sideInner').addEventListener('drop', onSidebarDrop);
  $('#listScroll').addEventListener('click', onListClick);
  $('#listScroll').addEventListener('contextmenu', onListContext);
  $('#listScroll').addEventListener('dragstart', onListDragStart);
  $('#listScroll').addEventListener('dragend', onListDragEnd);
  $('#edTags').addEventListener('click', onTagsClick);
  $('#edTags').addEventListener('keydown', onTagsKeydown);
  $('#toolbar').addEventListener('mousedown', e => { if (e.target.closest('.tb')) e.preventDefault(); });
  $('#toolbar').addEventListener('click', onEditorToolbarClick);
  $('#editorPane').addEventListener('click', onHeaderClick);
  $('#empty').addEventListener('click', e => e.stopPropagation());
  $('#listPane').addEventListener('click', onHeaderClick);
  $('#searchInput').addEventListener('input', onSearchInput);
  $('#searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') e.target.blur(); });
  $('#sortSel').addEventListener('change', e => { db.ui.sort = e.target.value; renderAll(); persist(); });

  const titleEl = $('#edTitle');
  titleEl.addEventListener('input', onTitleInput);
  titleEl.addEventListener('keydown', onTitleKeydown);

  const edEl = ed();
  edEl.addEventListener('input', onEditorInputEvent);
  edEl.addEventListener('keydown', onEditorKeydown);
  edEl.addEventListener('paste', onPaste);
  edEl.addEventListener('click', onEditorClick);
  edEl.addEventListener('keyup', updateToolbarState);
  edEl.addEventListener('mouseup', updateToolbarState);
  edEl.addEventListener('focus', updatePlaceholder);
  edEl.addEventListener('blur', updatePlaceholder);
  edEl.addEventListener('dragstart', e => e.preventDefault());
  edEl.addEventListener('drop', e => e.preventDefault());
  document.addEventListener('selectionchange', onSelectionChange);
  document.addEventListener('keydown', onGlobalKey);
  document.addEventListener('mousedown', e => { if (menuEl && !menuEl.contains(e.target)) closeMenu(); }, true);
  window.addEventListener('resize', closeMenu);
  $('#edScroll').addEventListener('scroll', closeMenu);
  window.addEventListener('beforeunload', flushSave);
  document.addEventListener('visibilitychange', () => { if (document.hidden) flushSave(); });
}

init();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"9411e0cb-b0ac-4afd-aba7-7102d0958bc5","tokensIn":386724,"tokensOut":150907,"tokensTotal":537631,"cost":0.087455075,"turns":9,"toolCalls":12,"failedToolCalls":0,"timestamp":"2026-10-08T00:42:23.335Z"} -->