"""HTML SPA template for self-contained memex conversation browser.

The template loads sql.js (Wasm) from CDN and can open a SQLite database
via URL parameter, fetch, or file picker. All CSS and JS are inlined
for single-file deployment.
"""


def get_template(schema_ddl: str = "") -> str:
    """Return the complete HTML5 SPA document as a string.

    Assembles the page from composable parts: CSS variables, layout,
    components, HTML structure, and JS modules.

    Parameters
    ----------
    schema_ddl : str
        Database schema DDL, embedded into the librarian chat system prompt.
    """
    return (
        '<!DOCTYPE html>\n<html lang="en">\n<head>\n'
        '<meta charset="utf-8">\n'
        '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
        '<title>memex</title>\n'
        '<style>\n'
        + _css_variables()
        + _css_layout()
        + _css_components()
        + '</style>\n</head>\n<body>\n'
        + _html_structure()
        + '<script src="https://cdn.jsdelivr.net/npm/sql.js@1.14.0/dist/sql-wasm.js"></script>\n'
        + '<script>\n'
        + _js_core()
        + _js_ui()
        + _js_chat(schema_ddl)
        + _js_settings()
        + '</script>\n</body>\n</html>'
    )


def _css_variables() -> str:
    """CSS custom properties for dark (default) and light themes."""
    return (
        ':root {\n'
        '  --bg: #0d1117;\n'
        '  --bg-surface: #161b22;\n'
        '  --bg-overlay: #1c2128;\n'
        '  --text: #c9d1d9;\n'
        '  --text-muted: #8b949e;\n'
        '  --text-accent: #58a6ff;\n'
        '  --text-strong: #f0f6fc;\n'
        '  --border: #30363d;\n'
        '  --border-accent: #58a6ff;\n'
        '  --badge-bg: rgba(56, 139, 253, 0.15);\n'
        '  --badge-text: #58a6ff;\n'
        '  --msg-user-bg: #161b22;\n'
        '  --msg-assistant-border: #58a6ff;\n'
        '  --success: #3fb950;\n'
        '  --warning: #d29922;\n'
        '  --danger: #f85149;\n'
        '  --selection: rgba(88, 166, 255, 0.15);\n'
        '  --sidebar-width: 300px;\n'
        '  --timeline-height: 48px;\n'
        '  --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;\n'
        '  --font-mono: "JetBrains Mono", "Fira Code", "SF Mono", "Cascadia Code",\n'
        '    "Source Code Pro", ui-monospace, Menlo, Monaco, "Courier New", monospace;\n'
        '  --font-size: 14px;\n'
        '  --font-size-sm: 12px;\n'
        '  --font-size-lg: 15px;\n'
        '  --radius: 6px;\n'
        '  --gap: 8px;\n'
        '}\n'
        '\n'
        ':root[data-theme="light"] {\n'
        '  --bg: #ffffff;\n'
        '  --bg-surface: #f6f8fa;\n'
        '  --bg-overlay: #f0f0f0;\n'
        '  --text: #24292f;\n'
        '  --text-muted: #656d76;\n'
        '  --text-accent: #0969da;\n'
        '  --text-strong: #24292f;\n'
        '  --border: #d0d7de;\n'
        '  --border-accent: #0969da;\n'
        '  --badge-bg: #ddf4ff;\n'
        '  --badge-text: #0969da;\n'
        '  --msg-user-bg: #f6f8fa;\n'
        '  --msg-assistant-border: #0969da;\n'
        '  --success: #1a7f37;\n'
        '  --warning: #9a6700;\n'
        '  --danger: #cf222e;\n'
        '  --selection: rgba(9, 105, 218, 0.15);\n'
        '}\n'
        '\n'
        '*,\n'
        '*::before,\n'
        '*::after {\n'
        '  box-sizing: border-box;\n'
        '  margin: 0;\n'
        '  padding: 0;\n'
        '}\n'
        '\n'
        'html, body {\n'
        '  height: 100%;\n'
        '  overflow: hidden;\n'
        '  background: var(--bg);\n'
        '  color: var(--text);\n'
        '  font-family: var(--font-sans);\n'
        '  font-size: var(--font-size);\n'
        '  line-height: 1.5;\n'
        '}\n'
        '\n'
        '::selection {\n'
        '  background: var(--selection);\n'
        '}\n'
        '\n'
        '::-webkit-scrollbar {\n'
        '  width: 6px;\n'
        '  height: 6px;\n'
        '}\n'
        '\n'
        '::-webkit-scrollbar-track {\n'
        '  background: transparent;\n'
        '}\n'
        '\n'
        '::-webkit-scrollbar-thumb {\n'
        '  background: var(--border);\n'
        '  border-radius: 3px;\n'
        '}\n'
        '\n'
        '::-webkit-scrollbar-thumb:hover {\n'
        '  background: var(--text-muted);\n'
        '}\n'
        '\n'
    )


def _css_layout() -> str:
    """Grid layout: sidebar, main content area, timeline bar."""
    return (
        '/* Layout: three-panel grid */\n'
        '#app {\n'
        '  display: grid;\n'
        '  grid-template-columns: var(--sidebar-width) 1fr;\n'
        '  grid-template-rows: 1fr var(--timeline-height);\n'
        '  height: 100vh;\n'
        '  width: 100vw;\n'
        '}\n'
        '\n'
        '/* Sidebar */\n'
        '#sidebar {\n'
        '  grid-row: 1 / -1;\n'
        '  grid-column: 1;\n'
        '  display: flex;\n'
        '  flex-direction: column;\n'
        '  background: var(--bg-surface);\n'
        '  border-right: 1px solid var(--border);\n'
        '  overflow: hidden;\n'
        '}\n'
        '\n'
        '#sidebar-header {\n'
        '  padding: var(--gap);\n'
        '  border-bottom: 1px solid var(--border);\n'
        '  flex-shrink: 0;\n'
        '}\n'
        '\n'
        '#conv-list {\n'
        '  flex: 1;\n'
        '  overflow-y: auto;\n'
        '  padding: 4px 0;\n'
        '}\n'
        '\n'
        '#sidebar-footer {\n'
        '  padding: var(--gap);\n'
        '  border-top: 1px solid var(--border);\n'
        '  flex-shrink: 0;\n'
        '  display: flex;\n'
        '  gap: var(--gap);\n'
        '  align-items: center;\n'
        '}\n'
        '\n'
        '#sidebar-footer .status {\n'
        '  color: var(--text-muted);\n'
        '  font-size: 11px;\n'
        '}\n'
        '\n'
        '/* Main content area */\n'
        '#main {\n'
        '  grid-row: 1;\n'
        '  grid-column: 2;\n'
        '  display: flex;\n'
        '  flex-direction: column;\n'
        '  overflow: hidden;\n'
        '}\n'
        '\n'
        '#main-header {\n'
        '  padding: var(--gap) 12px;\n'
        '  border-bottom: 1px solid var(--border);\n'
        '  flex-shrink: 0;\n'
        '  display: flex;\n'
        '  align-items: center;\n'
        '  justify-content: space-between;\n'
        '  min-height: 40px;\n'
        '}\n'
        '\n'
        '#main-header h2 {\n'
        '  font-size: var(--font-size-lg);\n'
        '  font-weight: 600;\n'
        '  color: var(--text-strong);\n'
        '  white-space: nowrap;\n'
        '  overflow: hidden;\n'
        '  text-overflow: ellipsis;\n'
        '}\n'
        '\n'
        '#messages {\n'
        '  flex: 1;\n'
        '  overflow-y: auto;\n'
        '  padding: 12px;\n'
        '}\n'
        '\n'
        '/* Timeline bar */\n'
        '#timeline {\n'
        '  grid-row: 2;\n'
        '  grid-column: 2;\n'
        '  background: var(--bg-surface);\n'
        '  border-top: 1px solid var(--border);\n'
        '  display: flex;\n'
        '  align-items: center;\n'
        '  padding: 0 12px;\n'
        '  overflow: hidden;\n'
        '}\n'
        '\n'
        '#timeline-canvas {\n'
        '  flex: 1;\n'
        '  height: 32px;\n'
        '  cursor: crosshair;\n'
        '}\n'
        '\n'
        '.timeline-label {\n'
        '  color: var(--text-muted);\n'
        '  font-size: 10px;\n'
        '  white-space: nowrap;\n'
        '  user-select: none;\n'
        '  min-width: 0;\n'
        '}\n'
        '\n'
    )


def _css_components() -> str:
    """CSS for messages, badges, filters, settings, input, buttons, etc."""
    return (
        '/* Search box */\n'
        '#search-box {\n'
        '  width: 100%;\n'
        '  padding: 6px 10px;\n'
        '  background: var(--bg);\n'
        '  color: var(--text);\n'
        '  border: 1px solid var(--border);\n'
        '  border-radius: var(--radius);\n'
        '  font-family: var(--font-sans);\n'
        '  font-size: var(--font-size-sm);\n'
        '  outline: none;\n'
        '  transition: border-color 0.15s;\n'
        '}\n'
        '\n'
        '#search-box:focus {\n'
        '  border-color: var(--border-accent);\n'
        '}\n'
        '\n'
        '#search-box::placeholder {\n'
        '  color: var(--text-muted);\n'
        '}\n'
        '\n'
        '/* Conversation list items */\n'
        '.conv-item {\n'
        '  padding: 8px var(--gap);\n'
        '  cursor: pointer;\n'
        '  border-left: 2px solid transparent;\n'
        '  transition: background 0.1s, border-color 0.1s;\n'
        '}\n'
        '\n'
        '.conv-item:hover {\n'
        '  background: var(--bg-overlay);\n'
        '}\n'
        '\n'
        '.conv-item.active {\n'
        '  background: var(--selection);\n'
        '  border-left-color: var(--text-accent);\n'
        '}\n'
        '\n'
        '.conv-item-title {\n'
        '  color: var(--text-strong);\n'
        '  font-size: 14px;\n'
        '  font-weight: 600;\n'
        '  white-space: nowrap;\n'
        '  overflow: hidden;\n'
        '  text-overflow: ellipsis;\n'
        '}\n'
        '\n'
        '.conv-item-meta {\n'
        '  color: var(--text-muted);\n'
        '  font-size: 12px;\n'
        '  margin-top: 2px;\n'
        '}\n'
        '\n'
        '/* Messages */\n'
        '.message {\n'
        '  margin-bottom: 12px;\n'
        '  padding: 10px 14px;\n'
        '  border-radius: var(--radius);\n'
        '}\n'
        '\n'
        '.message.user {\n'
        '  background: var(--msg-user-bg);\n'
        '}\n'
        '\n'
        '.message.assistant {\n'
        '  border-left: 3px solid var(--msg-assistant-border);\n'
        '}\n'
        '\n'
        '.message.system {\n'
        '  border-left: 3px solid var(--warning);\n'
        '}\n'
        '\n'
        '.message-role {\n'
        '  font-size: 11px;\n'
        '  font-weight: 600;\n'
        '  text-transform: uppercase;\n'
        '  letter-spacing: 0.05em;\n'
        '  margin-bottom: 4px;\n'
        '}\n'
        '\n'
        '.message.user .message-role { color: var(--text-accent); }\n'
        '.message.assistant .message-role { color: var(--text-accent); }\n'
        '.message.system .message-role { color: var(--warning); }\n'
        '\n'
        '.message-content {\n'
        '  word-break: break-word;\n'
        '}\n'
        '\n'
        '/* Message content rendering */\n'
        '.message-content pre {\n'
        '  background: var(--bg);\n'
        '  border: 1px solid var(--border);\n'
        '  border-radius: var(--radius);\n'
        '  padding: 8px;\n'
        '  overflow-x: auto;\n'
        '  margin: 8px 0;\n'
        '  white-space: pre;\n'
        '  font-family: var(--font-mono);\n'
        '}\n'
        '\n'
        '.message-content code {\n'
        '  background: var(--bg);\n'
        '  padding: 1px 4px;\n'
        '  border-radius: 2px;\n'
        '  font-size: var(--font-size-sm);\n'
        '  font-family: var(--font-mono);\n'
        '}\n'
        '\n'
        '.message-content pre code {\n'
        '  background: none;\n'
        '  padding: 0;\n'
        '}\n'
        '\n'
        '.message-content img {\n'
        '  max-width: 100%;\n'
        '  border-radius: var(--radius);\n'
        '  margin: 4px 0;\n'
        '}\n'
        '\n'
        '.message-content audio,\n'
        '.message-content video {\n'
        '  max-width: 100%;\n'
        '  margin: 4px 0;\n'
        '}\n'
        '\n'
        '.message-content h1,\n'
        '.message-content h2,\n'
        '.message-content h3,\n'
        '.message-content h4 {\n'
        '  color: var(--text-strong);\n'
        '  margin: 12px 0 4px 0;\n'
        '}\n'
        '\n'
        '.message-content ul {\n'
        '  padding-left: 20px;\n'
        '  margin: 4px 0;\n'
        '}\n'
        '\n'
        '.message-content hr {\n'
        '  border: none;\n'
        '  border-top: 1px solid var(--border);\n'
        '  margin: 8px 0;\n'
        '}\n'
        '\n'
        '.message-content a {\n'
        '  color: var(--text-accent);\n'
        '  text-decoration: none;\n'
        '}\n'
        '\n'
        '.message-content a:hover {\n'
        '  text-decoration: underline;\n'
        '}\n'
        '\n'
        '.message-content p {\n'
        '  margin: 4px 0;\n'
        '}\n'
        '\n'
        '/* Message input area */\n'
        '#input-area {\n'
        '  padding: var(--gap) 12px;\n'
        '  border-top: 1px solid var(--border);\n'
        '  flex-shrink: 0;\n'
        '  display: flex;\n'
        '  gap: var(--gap);\n'
        '}\n'
        '\n'
        '#message-input {\n'
        '  flex: 1;\n'
        '  padding: 6px 10px;\n'
        '  background: var(--bg);\n'
        '  color: var(--text);\n'
        '  border: 1px solid var(--border);\n'
        '  border-radius: var(--radius);\n'
        '  font-family: var(--font-sans);\n'
        '  font-size: var(--font-size-sm);\n'
        '  outline: none;\n'
        '  resize: none;\n'
        '}\n'
        '\n'
        '#message-input:focus {\n'
        '  border-color: var(--border-accent);\n'
        '}\n'
        '\n'
        '/* Filter chips */\n'
        '#filters {\n'
        '  padding: 4px var(--gap);\n'
        '  border-bottom: 1px solid var(--border);\n'
        '  flex-shrink: 0;\n'
        '  display: flex;\n'
        '  flex-wrap: wrap;\n'
        '  gap: 4px;\n'
        '}\n'
        '\n'
        '#filters:empty {\n'
        '  display: none;\n'
        '  padding: 0;\n'
        '  border: 0;\n'
        '}\n'
        '\n'
        '.filter-chip {\n'
        '  display: inline-block;\n'
        '  padding: 2px 10px;\n'
        '  font-size: 12px;\n'
        '  color: var(--text-muted);\n'
        '  background: transparent;\n'
        '  border: 1px solid var(--border);\n'
        '  border-radius: 12px;\n'
        '  cursor: pointer;\n'
        '  transition: background 0.1s, border-color 0.1s, color 0.1s;\n'
        '  white-space: nowrap;\n'
        '  user-select: none;\n'
        '}\n'
        '\n'
        '.filter-chip:hover {\n'
        '  border-color: var(--text-muted);\n'
        '  color: var(--text);\n'
        '}\n'
        '\n'
        '.filter-chip.active {\n'
        '  background: var(--text-accent);\n'
        '  border-color: var(--text-accent);\n'
        '  color: var(--bg);\n'
        '}\n'
        '\n'
        '/* Conversation header */\n'
        '.conv-header {\n'
        '  margin-bottom: 16px;\n'
        '  padding-bottom: 12px;\n'
        '  border-bottom: 1px solid var(--border);\n'
        '}\n'
        '\n'
        '.conv-header h2 {\n'
        '  font-size: var(--font-size-lg);\n'
        '  color: var(--text-strong);\n'
        '  margin-bottom: 4px;\n'
        '}\n'
        '\n'
        '.conv-header-meta {\n'
        '  color: var(--text-muted);\n'
        '  font-size: var(--font-size-sm);\n'
        '  margin-bottom: 6px;\n'
        '}\n'
        '\n'
        '.conv-header-tags {\n'
        '  display: flex;\n'
        '  flex-wrap: wrap;\n'
        '  gap: 4px;\n'
        '}\n'
        '\n'
        '.conv-tag {\n'
        '  display: inline-block;\n'
        '  padding: 2px 8px;\n'
        '  font-size: 11px;\n'
        '  color: var(--badge-text);\n'
        '  background: var(--badge-bg);\n'
        '  border-radius: 12px;\n'
        '}\n'
        '\n'
        '/* Buttons */\n'
        '.btn {\n'
        '  padding: 6px 12px;\n'
        '  background: var(--bg-overlay);\n'
        '  color: var(--text);\n'
        '  border: 1px solid var(--border);\n'
        '  border-radius: var(--radius);\n'
        '  font-family: var(--font-sans);\n'
        '  font-size: var(--font-size-sm);\n'
        '  cursor: pointer;\n'
        '  transition: background 0.1s, border-color 0.1s;\n'
        '}\n'
        '\n'
        '.btn:hover {\n'
        '  background: var(--bg-surface);\n'
        '  border-color: var(--text-muted);\n'
        '}\n'
        '\n'
        '.btn-primary {\n'
        '  background: var(--text-accent);\n'
        '  color: var(--bg);\n'
        '  border-color: var(--text-accent);\n'
        '}\n'
        '\n'
        '.btn-primary:hover {\n'
        '  opacity: 0.9;\n'
        '}\n'
        '\n'
        '/* Settings overlay */\n'
        '#settings-overlay {\n'
        '  display: none;\n'
        '  position: fixed;\n'
        '  inset: 0;\n'
        '  background: rgba(0, 0, 0, 0.6);\n'
        '  z-index: 100;\n'
        '  align-items: center;\n'
        '  justify-content: center;\n'
        '}\n'
        '\n'
        '#settings-overlay.visible {\n'
        '  display: flex;\n'
        '}\n'
        '\n'
        '#settings-panel {\n'
        '  background: var(--bg-surface);\n'
        '  border: 1px solid var(--border);\n'
        '  border-radius: var(--radius);\n'
        '  padding: 20px;\n'
        '  width: 480px;\n'
        '  max-width: 90vw;\n'
        '  max-height: 80vh;\n'
        '  overflow-y: auto;\n'
        '}\n'
        '\n'
        '#settings-panel h3 {\n'
        '  color: var(--text-strong);\n'
        '  margin-bottom: 12px;\n'
        '}\n'
        '\n'
        '.settings-field {\n'
        '  margin-bottom: 12px;\n'
        '}\n'
        '\n'
        '.settings-field label {\n'
        '  display: block;\n'
        '  color: var(--text-muted);\n'
        '  font-size: var(--font-size-sm);\n'
        '  margin-bottom: 4px;\n'
        '}\n'
        '\n'
        '.settings-field input,\n'
        '.settings-field textarea {\n'
        '  width: 100%;\n'
        '  padding: 6px 10px;\n'
        '  background: var(--bg);\n'
        '  color: var(--text);\n'
        '  border: 1px solid var(--border);\n'
        '  border-radius: var(--radius);\n'
        '  font-family: var(--font-sans);\n'
        '  font-size: var(--font-size-sm);\n'
        '  outline: none;\n'
        '}\n'
        '\n'
        '.settings-field input:focus,\n'
        '.settings-field textarea:focus {\n'
        '  border-color: var(--border-accent);\n'
        '}\n'
        '\n'
        '.settings-field textarea {\n'
        '  resize: vertical;\n'
        '}\n'
        '\n'
        '.settings-actions {\n'
        '  display: flex;\n'
        '  gap: var(--gap);\n'
        '  margin-top: 16px;\n'
        '}\n'
        '\n'
        '/* File picker / drop zone */\n'
        '#drop-zone {\n'
        '  display: none;\n'
        '  position: fixed;\n'
        '  inset: 0;\n'
        '  background: rgba(0, 0, 0, 0.7);\n'
        '  z-index: 200;\n'
        '  align-items: center;\n'
        '  justify-content: center;\n'
        '  flex-direction: column;\n'
        '  gap: 16px;\n'
        '}\n'
        '\n'
        '#drop-zone.visible {\n'
        '  display: flex;\n'
        '}\n'
        '\n'
        '#drop-zone-inner {\n'
        '  border: 2px dashed var(--border-accent);\n'
        '  border-radius: 8px;\n'
        '  padding: 48px 64px;\n'
        '  text-align: center;\n'
        '  color: var(--text-accent);\n'
        '  font-size: var(--font-size-lg);\n'
        '}\n'
        '\n'
        '#drop-zone-inner p {\n'
        '  margin-bottom: 12px;\n'
        '}\n'
        '\n'
        '#file-input {\n'
        '  display: none;\n'
        '}\n'
        '\n'
        '/* Loading state */\n'
        '#loading {\n'
        '  display: flex;\n'
        '  align-items: center;\n'
        '  justify-content: center;\n'
        '  height: 100%;\n'
        '  color: var(--text-muted);\n'
        '  font-size: var(--font-size-lg);\n'
        '}\n'
        '\n'
        '/* Utility classes */\n'
        '.hidden { display: none !important; }\n'
        '\n'
        '/* Welcome state */\n'
        '.welcome {\n'
        '  display: flex;\n'
        '  flex-direction: column;\n'
        '  align-items: center;\n'
        '  justify-content: center;\n'
        '  height: 100%;\n'
        '}\n'
        '\n'
        '.welcome h2 {\n'
        '  font-size: 20px;\n'
        '  font-weight: 600;\n'
        '  color: var(--text-strong);\n'
        '}\n'
        '\n'
        '.welcome p {\n'
        '  font-size: 14px;\n'
        '  color: var(--text-muted);\n'
        '}\n'
        '\n'
        '.starter-prompts {\n'
        '  display: grid;\n'
        '  grid-template-columns: 1fr 1fr;\n'
        '  max-width: 480px;\n'
        '  gap: 8px;\n'
        '  margin-top: 16px;\n'
        '}\n'
        '\n'
        '.starter-prompt {\n'
        '  padding: 12px 16px;\n'
        '  border: 1px solid var(--border);\n'
        '  border-radius: 8px;\n'
        '  cursor: pointer;\n'
        '  font-size: var(--font-size-sm);\n'
        '  color: var(--text);\n'
        '  transition: border-color 0.15s;\n'
        '}\n'
        '\n'
        '.starter-prompt:hover {\n'
        '  border-color: var(--text-accent);\n'
        '}\n'
    )


def _html_structure() -> str:
    """The DOM skeleton: sidebar, main, timeline, settings, drop zone."""
    return (
        '<div id="app">\n'
        '\n'
        '  <!-- Sidebar -->\n'
        '  <div id="sidebar">\n'
        '    <div id="sidebar-header">\n'
        '      <input type="text" id="search-box" placeholder="search conversations..." autocomplete="off" spellcheck="false">\n'
        '    </div>\n'
        '    <div id="filters"></div>\n'
        '    <div id="conv-list"></div>\n'
        '    <div id="sidebar-footer">\n'
        '      <span class="status" id="db-status">no database loaded</span>\n'
        '    </div>\n'
        '  </div>\n'
        '\n'
        '  <!-- Main content -->\n'
        '  <div id="main">\n'
        '    <div id="main-header">\n'
        '      <h2 id="conv-title" onclick="goHome()" style="cursor: pointer;">memex</h2>\n'
        '      <div id="main-actions">'
        '<button class="btn" id="theme-toggle" onclick="toggleTheme()" title="toggle light/dark mode">&#9790;</button>'
        '<button class="btn" onclick="toggleSettings()" title="settings">&#9881;</button>'
        '</div>\n'
        '    </div>\n'
        '    <div id="messages">\n'
        '      <div id="loading">loading...</div>\n'
        '      <div id="welcome" class="welcome hidden">\n'
        '        <h2>memex</h2>\n'
        '        <p>Ask me anything about your conversations.</p>\n'
        '        <div class="starter-prompts">\n'
        '          <div class="starter-prompt" onclick="askLibrarian(this.textContent)">What topics have I discussed most?</div>\n'
        '          <div class="starter-prompt" onclick="askLibrarian(this.textContent)">Find conversations about Python</div>\n'
        '          <div class="starter-prompt" onclick="askLibrarian(this.textContent)">Which conversations have the most messages?</div>\n'
        '          <div class="starter-prompt" onclick="askLibrarian(this.textContent)">Show my recent activity</div>\n'
        '        </div>\n'
        '      </div>\n'
        '    </div>\n'
        '    <div id="input-area" class="hidden">\n'
        '      <textarea id="message-input" rows="1" placeholder="add a note..."></textarea>\n'
        '      <button class="btn btn-primary" id="send-btn" onclick="sendMessage()">send</button>\n'
        '    </div>\n'
        '  </div>\n'
        '\n'
        '  <!-- Timeline bar -->\n'
        '  <div id="timeline">\n'
        '    <span id="timeline-start" class="timeline-label"></span>\n'
        '    <canvas id="timeline-canvas"></canvas>\n'
        '    <span id="timeline-end" class="timeline-label"></span>\n'
        '  </div>\n'
        '\n'
        '</div>\n'
        '\n'
        '<!-- Settings overlay -->\n'
        '<div id="settings-overlay">\n'
        '  <div id="settings-panel">\n'
        '    <h3>settings</h3>\n'
        '    <div class="settings-field">\n'
        '      <label for="setting-endpoint">API endpoint</label>\n'
        '      <input type="text" id="setting-endpoint" placeholder="https://metafunctor-edge.queelius.workers.dev/v1/messages" autocomplete="off" spellcheck="false">\n'
        '    </div>\n'
        '    <div class="settings-field">\n'
        '      <label for="setting-api-key">API key (optional if using proxy)</label>\n'
        '      <input type="password" id="setting-api-key" placeholder="sk-ant-..." autocomplete="off" spellcheck="false">\n'
        '    </div>\n'
        '    <div class="settings-field">\n'
        '      <label for="setting-system-prompt">System prompt (optional)</label>\n'
        '      <textarea id="setting-system-prompt" rows="4" placeholder="You are a helpful assistant..."></textarea>\n'
        '    </div>\n'
        '    <div class="settings-actions">\n'
        '      <button class="btn btn-primary" onclick="saveSettings()">save</button>\n'
        '      <button class="btn" onclick="toggleSettings()">close</button>\n'
        '    </div>\n'
        '  </div>\n'
        '</div>\n'
        '\n'
        '<!-- File picker / drop zone -->\n'
        '<div id="drop-zone">\n'
        '  <div id="drop-zone-inner">\n'
        '    <p>drop a .db file here</p>\n'
        '    <p style="font-size: var(--font-size-sm); color: var(--text-muted);">or</p>\n'
        '    <button class="btn btn-primary" onclick="document.getElementById(&quot;file-input&quot;).click()">choose file</button>\n'
        '  </div>\n'
        '  <input type="file" id="file-input" accept=".db,.sqlite,.sqlite3">\n'
        '</div>\n'
        '\n'
    )


def _js_core() -> str:
    """DB loading, query helpers, state management."""
    return (
        '/* -- globals --------------------------------------------------------- */\n'
        'var db = null;\n'
        'var WASM_URL = "https://cdn.jsdelivr.net/npm/sql.js@1.14.0/dist/sql-wasm.wasm";\n'
        '\n'
        '/* -- query helpers --------------------------------------------------- */\n'
        '\n'
        '/**\n'
        ' * Run a SELECT query and return an array of plain objects.\n'
        ' * @param {string} sql - SQL query string\n'
        ' * @param {Object|Array} [params] - Bind parameters\n'
        ' * @returns {Array<Object>} Array of row objects\n'
        ' */\n'
        'function query(sql, params) {\n'
        '  if (!db) throw new Error("No database loaded");\n'
        '  var stmt = db.prepare(sql);\n'
        '  try {\n'
        '    if (params) stmt.bind(params);\n'
        '    var results = [];\n'
        '    while (stmt.step()) {\n'
        '      results.push(stmt.getAsObject());\n'
        '    }\n'
        '    return results;\n'
        '  } finally {\n'
        '    stmt.free();\n'
        '  }\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Run a write statement (INSERT, UPDATE, DELETE).\n'
        ' * @param {string} sql - SQL statement\n'
        ' * @param {Object|Array} [params] - Bind parameters\n'
        ' */\n'
        'function exec(sql, params) {\n'
        '  if (!db) throw new Error("No database loaded");\n'
        '  if (params) {\n'
        '    db.run(sql, params);\n'
        '  } else {\n'
        '    db.run(sql);\n'
        '  }\n'
        '}\n'
        '\n'
        '/* -- DB loading cascade ---------------------------------------------- */\n'
        '\n'
        '/**\n'
        ' * Initialize the application: load sql.js and attempt to open a database.\n'
        ' *\n'
        ' * Loading cascade:\n'
        ' * 1. Check for ?db=path URL parameter\n'
        ' * 2. Try fetching ./conversations.db\n'
        ' * 3. Fall back to file picker (drag-and-drop + input)\n'
        ' */\n'
        'async function initApp() {\n'
        '  var SQL = await initSqlJs({\n'
        '    locateFile: function() { return WASM_URL; }\n'
        '  });\n'
        '\n'
        '  // 1. Check URL parameter\n'
        '  var urlParams = new URLSearchParams(window.location.search);\n'
        '  var dbParam = urlParams.get("db");\n'
        '\n'
        '  if (dbParam) {\n'
        '    try {\n'
        '      var buf = await fetchDb(dbParam);\n'
        '      db = new SQL.Database(new Uint8Array(buf));\n'
        '      onDbLoaded();\n'
        '      return;\n'
        '    } catch (e) {\n'
        '      console.warn("Failed to load DB from ?db= param:", e.message);\n'
        '    }\n'
        '  }\n'
        '\n'
        '  // 2. Try default path\n'
        '  try {\n'
        '    var buf2 = await fetchDb("./conversations.db");\n'
        '    db = new SQL.Database(new Uint8Array(buf2));\n'
        '    onDbLoaded();\n'
        '    return;\n'
        '  } catch (e) {\n'
        '    console.warn("No conversations.db found:", e.message);\n'
        '  }\n'
        '\n'
        '  // 3. Fall back to file picker\n'
        '  showFilePicker(SQL);\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Fetch a database file and return its ArrayBuffer.\n'
        ' * @param {string} url - URL to fetch\n'
        ' * @returns {Promise<ArrayBuffer>}\n'
        ' */\n'
        'async function fetchDb(url) {\n'
        '  var resp = await fetch(url);\n'
        '  if (!resp.ok) throw new Error("HTTP " + resp.status);\n'
        '  return await resp.arrayBuffer();\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Show the drag-and-drop file picker overlay.\n'
        ' * @param {Object} SQL - Initialized sql.js module\n'
        ' */\n'
        'function showFilePicker(SQL) {\n'
        '  var dropZone = document.getElementById("drop-zone");\n'
        '  var fileInput = document.getElementById("file-input");\n'
        '  var loading = document.getElementById("loading");\n'
        '\n'
        '  loading.textContent = "no database found";\n'
        '  dropZone.classList.add("visible");\n'
        '\n'
        '  function handleFile(file) {\n'
        '    var reader = new FileReader();\n'
        '    reader.onload = function() {\n'
        '      db = new SQL.Database(new Uint8Array(reader.result));\n'
        '      dropZone.classList.remove("visible");\n'
        '      onDbLoaded();\n'
        '    };\n'
        '    reader.readAsArrayBuffer(file);\n'
        '  }\n'
        '\n'
        '  fileInput.addEventListener("change", function(e) {\n'
        '    if (e.target.files.length > 0) handleFile(e.target.files[0]);\n'
        '  });\n'
        '\n'
        '  dropZone.addEventListener("dragover", function(e) {\n'
        '    e.preventDefault();\n'
        '    e.dataTransfer.dropEffect = "copy";\n'
        '  });\n'
        '\n'
        '  dropZone.addEventListener("drop", function(e) {\n'
        '    e.preventDefault();\n'
        '    if (e.dataTransfer.files.length > 0) handleFile(e.dataTransfer.files[0]);\n'
        '  });\n'
        '}\n'
        '\n'
        '/* -- state ----------------------------------------------------------- */\n'
        'var activeFilters = { source: null, tag: null, starred: false, dateFrom: null, dateTo: null };\n'
        'var activeConvId = null;\n'
        'var searchTimer = null;\n'
        'var searchWired = false;\n'
        'var totalConvCount = 0;\n'
        'var timelineData = [];\n'
        'var timelineSelection = null;\n'
        '\n'
        '/* -- helpers --------------------------------------------------------- */\n'
        '\n'
        '/**\n'
        ' * XSS-safe HTML escaping.\n'
        ' * @param {string} s - Raw string\n'
        ' * @returns {string} Escaped HTML string\n'
        ' */\n'
        'function esc(s) {\n'
        '  if (s == null) return "";\n'
        '  var d = document.createElement("div");\n'
        '  d.textContent = String(s);\n'
        '  return d.innerHTML;\n'
        '}\n'
        '\n'
        'function escAttr(s) {\n'
        '  return esc(s).replace(/"/g, "&quot;").replace(/\'/g, "&#39;");\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Format an ISO date string to a short display format.\n'
        ' * @param {string} iso - ISO date string\n'
        ' * @returns {string} Formatted date\n'
        ' */\n'
        'function fmtDate(iso) {\n'
        '  if (!iso) return "";\n'
        '  try {\n'
        '    var d = new Date(iso);\n'
        '    return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });\n'
        '  } catch (e) {\n'
        '    return String(iso).substring(0, 10);\n'
        '  }\n'
        '}\n'
        '\n'
    )


def _js_ui() -> str:
    """Rendering, filters, conversation display, timeline."""
    return (
        '/* -- app lifecycle --------------------------------------------------- */\n'
        'var chatMode = "librarian";\n'
        '\n'
        '/** Called after a database is successfully loaded. */\n'
        'function onDbLoaded() {\n'
        '  var status = document.getElementById("db-status");\n'
        '  var loading = document.getElementById("loading");\n'
        '  var welcome = document.getElementById("welcome");\n'
        '  var inputArea = document.getElementById("input-area");\n'
        '\n'
        '  /* Update status */\n'
        '  var countRow = query("SELECT count(*) AS n FROM conversations");\n'
        '  totalConvCount = countRow.length > 0 ? countRow[0].n : 0;\n'
        '  status.textContent = totalConvCount + " conversation" + (totalConvCount !== 1 ? "s" : "");\n'
        '  status.style.color = "var(--success)";\n'
        '  loading.classList.add("hidden");\n'
        '  welcome.classList.remove("hidden");\n'
        '  inputArea.classList.remove("hidden");\n'
        '  chatMode = "librarian";\n'
        '\n'
        '  /* Render filter chips */\n'
        '  renderFilters();\n'
        '\n'
        '  /* Initialize timeline (stub called, filled in Task 4) */\n'
        '  initTimeline();\n'
        '\n'
        '  /* Load conversations */\n'
        '  loadConversations();\n'
        '\n'
        '  /* Wire up debounced search (once only) */\n'
        '  if (!searchWired) {\n'
        '    var searchBox = document.getElementById("search-box");\n'
        '    searchBox.addEventListener("input", function() {\n'
        '      clearTimeout(searchTimer);\n'
        '      searchTimer = setTimeout(function() {\n'
        '        loadConversations();\n'
        '      }, 200);\n'
        '    });\n'
        '    searchWired = true;\n'
        '  }\n'
        '\n'
        '  /* Load settings from localStorage */\n'
        '  loadSettings();\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Render source chips, tag chips (top 10), and starred toggle.\n'
        ' * Click toggles activeFilters and reloads the conversation list.\n'
        ' *\n'
        ' * All user-provided strings are escaped via esc() before insertion\n'
        ' * to prevent XSS. The esc() function uses textContent assignment\n'
        ' * on a detached element, which is a standard safe escaping pattern.\n'
        ' */\n'
        'function renderFilters() {\n'
        '  var container = document.getElementById("filters");\n'
        '  var chips = [];\n'
        '\n'
        "  /* Source chips */\n"
        "  var sources = query(\"SELECT DISTINCT source FROM conversations WHERE source IS NOT NULL AND source != '' ORDER BY source\");\n"
        '  for (var i = 0; i < sources.length; i++) {\n'
        '    var src = sources[i].source;\n'
        '    var cls = activeFilters.source === src ? "filter-chip active" : "filter-chip";\n'
        '    chips.push({cls: cls, filter: "source", value: src, label: src});\n'
        '  }\n'
        '\n'
        '  /* Tag chips (top 10 by frequency) */\n'
        '  var tags = query("SELECT tag, count(*) AS n FROM tags GROUP BY tag ORDER BY n DESC LIMIT 10");\n'
        '  for (var j = 0; j < tags.length; j++) {\n'
        '    var tag = tags[j].tag;\n'
        '    var cls2 = activeFilters.tag === tag ? "filter-chip active" : "filter-chip";\n'
        '    chips.push({cls: cls2, filter: "tag", value: tag, label: "#" + tag});\n'
        '  }\n'
        '\n'
        '  /* Starred toggle */\n'
        '  var starCls = activeFilters.starred ? "filter-chip active" : "filter-chip";\n'
        '  chips.push({cls: starCls, filter: "starred", value: "", label: "starred"});\n'
        '\n'
        '  /* Build DOM safely */\n'
        '  container.textContent = "";\n'
        '  for (var k = 0; k < chips.length; k++) {\n'
        '    var chip = chips[k];\n'
        '    var span = document.createElement("span");\n'
        '    span.className = chip.cls;\n'
        '    span.setAttribute("data-filter", chip.filter);\n'
        '    span.setAttribute("data-value", chip.value);\n'
        '    span.textContent = chip.label;\n'
        '    span.addEventListener("click", function() {\n'
        '      var filterType = this.getAttribute("data-filter");\n'
        '      var filterValue = this.getAttribute("data-value");\n'
        '      if (filterType === "source") {\n'
        '        activeFilters.source = activeFilters.source === filterValue ? null : filterValue;\n'
        '      } else if (filterType === "tag") {\n'
        '        activeFilters.tag = activeFilters.tag === filterValue ? null : filterValue;\n'
        '      } else if (filterType === "starred") {\n'
        '        activeFilters.starred = !activeFilters.starred;\n'
        '      }\n'
        '      renderFilters();\n'
        '      loadConversations();\n'
        '    });\n'
        '    container.appendChild(span);\n'
        '  }\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Load the conversation list into the sidebar.\n'
        ' *\n'
        ' * Builds SQL dynamically based on search term and active filters.\n'
        ' * All user-provided strings are escaped via esc() before insertion\n'
        ' * to prevent XSS. Uses parameterized queries for SQL injection safety.\n'
        ' */\n'
        'function loadConversations() {\n'
        '  var searchBox = document.getElementById("search-box");\n'
        '  var searchTerm = searchBox ? searchBox.value.trim() : "";\n'
        '  var convList = document.getElementById("conv-list");\n'
        '\n'
        '  var sql, params = [];\n'
        '\n'
        '  if (searchTerm) {\n'
        "    sql = \"SELECT DISTINCT c.id, c.title, c.source, c.model, c.message_count, c.updated_at, c.starred_at FROM conversations c JOIN messages m ON m.conversation_id = c.id WHERE m.content LIKE '%' || ? || '%'\";\n"
        '    params.push(searchTerm);\n'
        '  } else {\n'
        '    sql = "SELECT c.id, c.title, c.source, c.model, c.message_count, c.updated_at, c.starred_at FROM conversations c WHERE 1=1";\n'
        '  }\n'
        '\n'
        '  /* Apply filters */\n'
        '  if (activeFilters.source) {\n'
        '    sql += " AND c.source = ?";\n'
        '    params.push(activeFilters.source);\n'
        '  }\n'
        '  if (activeFilters.tag) {\n'
        '    sql += " AND EXISTS (SELECT 1 FROM tags t WHERE t.conversation_id = c.id AND t.tag = ?)";\n'
        '    params.push(activeFilters.tag);\n'
        '  }\n'
        '  if (activeFilters.starred) {\n'
        '    sql += " AND c.starred_at IS NOT NULL";\n'
        '  }\n'
        '  if (activeFilters.dateFrom) {\n'
        '    sql += " AND c.updated_at >= ?";\n'
        '    params.push(activeFilters.dateFrom);\n'
        '  }\n'
        '  if (activeFilters.dateTo) {\n'
        '    sql += " AND c.updated_at <= ?";\n'
        '    params.push(activeFilters.dateTo);\n'
        '  }\n'
        '\n'
        '  sql += " ORDER BY c.updated_at DESC LIMIT 500";\n'
        '\n'
        '  var rows;\n'
        '  try {\n'
        '    rows = query(sql, params.length > 0 ? params : undefined);\n'
        '  } catch (e) {\n'
        '    console.error("loadConversations query error:", e);\n'
        '    rows = [];\n'
        '  }\n'
        '\n'
        '  /* Build DOM safely using createElement + textContent */\n'
        '  convList.textContent = "";\n'
        '  for (var i = 0; i < rows.length; i++) {\n'
        '    var r = rows[i];\n'
        '    var title = r.title || "Untitled";\n'
        '    var meta = [];\n'
        '    if (r.source) meta.push(r.source);\n'
        '    if (r.message_count != null) meta.push(r.message_count + " msgs");\n'
        '    if (r.updated_at) meta.push(fmtDate(r.updated_at));\n'
        '\n'
        '    var item = document.createElement("div");\n'
        '    item.className = "conv-item";\n'
        '    item.setAttribute("data-id", r.id);\n'
        '\n'
        '    var titleDiv = document.createElement("div");\n'
        '    titleDiv.className = "conv-item-title";\n'
        '    titleDiv.textContent = (r.starred_at ? "* " : "") + title;\n'
        '    item.appendChild(titleDiv);\n'
        '\n'
        '    var metaDiv = document.createElement("div");\n'
        '    metaDiv.className = "conv-item-meta";\n'
        '    metaDiv.textContent = meta.join(" \\u00b7 ");\n'
        '    item.appendChild(metaDiv);\n'
        '\n'
        '    (function(convId) {\n'
        '      item.addEventListener("click", function() { openConversation(convId); });\n'
        '    })(r.id);\n'
        '\n'
        '    convList.appendChild(item);\n'
        '  }\n'
        '\n'
        '  if (rows.length === 0) {\n'
        '    var empty = document.createElement("div");\n'
        '    empty.style.cssText = "padding: var(--gap); color: var(--text-muted); font-size: var(--font-size-sm);";\n'
        '    empty.textContent = "no conversations found";\n'
        '    convList.appendChild(empty);\n'
        '  }\n'
        '\n'
        '  /* Update footer count (uses cached totalConvCount from onDbLoaded) */\n'
        '  var status = document.getElementById("db-status");\n'
        '  if (status) {\n'
        '    if (searchTerm || activeFilters.source || activeFilters.tag || activeFilters.starred) {\n'
        '      status.textContent = rows.length + " / " + totalConvCount + " conversations";\n'
        '    } else {\n'
        '      status.textContent = totalConvCount + " conversation" + (totalConvCount !== 1 ? "s" : "");\n'
        '    }\n'
        '  }\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Open a conversation by ID and render it in the main panel.\n'
        ' * @param {string} convId - Conversation ID\n'
        ' */\n'
        'function openConversation(convId) {\n'
        '  activeConvId = convId;\n'
        '  chatMode = "conversation";\n'
        '  var welcome = document.getElementById("welcome");\n'
        '  if (welcome) welcome.classList.add("hidden");\n'
        '\n'
        '  /* Highlight active sidebar item */\n'
        '  var items = document.querySelectorAll(".conv-item");\n'
        '  for (var i = 0; i < items.length; i++) {\n'
        '    items[i].classList.toggle("active", items[i].getAttribute("data-id") === convId);\n'
        '  }\n'
        '\n'
        '  /* Query conversation metadata */\n'
        '  var convRows = query("SELECT * FROM conversations WHERE id = ?", [convId]);\n'
        '  if (convRows.length === 0) return;\n'
        '  var conv = convRows[0];\n'
        '\n'
        '  /* Query messages ordered by created_at */\n'
        '  var messages = query(\n'
        '    "SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",\n'
        '    [convId]\n'
        '  );\n'
        '\n'
        '  /* Query tags */\n'
        '  var tagRows = query("SELECT tag FROM tags WHERE conversation_id = ?", [convId]);\n'
        '  var tags = [];\n'
        '  for (var t = 0; t < tagRows.length; t++) tags.push(tagRows[t].tag);\n'
        '\n'
        '  renderConversation(conv, messages, tags);\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Render conversation header, messages, and input area.\n'
        ' * @param {Object} conv - Conversation row\n'
        ' * @param {Array} messages - Message rows\n'
        ' * @param {Array<string>} tags - Tag strings\n'
        ' */\n'
        'function renderConversation(conv, messages, tags) {\n'
        '  var container = document.getElementById("messages");\n'
        '  var titleEl = document.getElementById("conv-title");\n'
        '  var inputArea = document.getElementById("input-area");\n'
        '\n'
        '  /* Update header title */\n'
        '  titleEl.textContent = conv.title || "Untitled";\n'
        '\n'
        '  /* Build messages HTML */\n'
        '  var html = "";\n'
        '\n'
        '  /* Conversation header block */\n'
        '  html += \'<div class="conv-header">\';\n'
        '  html += "<h2>" + esc(conv.title || "Untitled") + "</h2>";\n'
        '  var meta = [];\n'
        '  if (conv.source) meta.push(esc(conv.source));\n'
        '  if (conv.model) meta.push(esc(conv.model));\n'
        '  meta.push(messages.length + " messages");\n'
        '  if (conv.created_at) meta.push(fmtDate(conv.created_at));\n'
        '  html += \'<div class="conv-header-meta">\' + meta.join(" &middot; ") + "</div>";\n'
        '  if (tags.length > 0) {\n'
        '    html += \'<div class="conv-header-tags">\';\n'
        '    for (var t = 0; t < tags.length; t++) {\n'
        '      html += \'<span class="conv-tag">\' + esc(tags[t]) + "</span>";\n'
        '    }\n'
        '    html += "</div>";\n'
        '  }\n'
        '  html += "</div>";\n'
        '\n'
        '  /* Render each message */\n'
        '  for (var i = 0; i < messages.length; i++) {\n'
        '    var msg = messages[i];\n'
        '    var role = msg.role || "unknown";\n'
        '    var roleCls = (role === "user" || role === "assistant" || role === "system") ? role : "";\n'
        '    html += \'<div class="message \' + roleCls + \'">\';'
        '\n'
        '    html += \'<div class="message-role">\' + esc(role) + "</div>";\n'
        '    html += \'<div class="message-content">\';\n'
        '\n'
        '    /* Parse content blocks from JSON */\n'
        '    var blocks = [];\n'
        '    try {\n'
        '      var parsed = JSON.parse(msg.content);\n'
        '      if (Array.isArray(parsed)) {\n'
        '        blocks = parsed;\n'
        '      } else if (typeof parsed === "string") {\n'
        '        blocks = [{ type: "text", text: parsed }];\n'
        '      } else {\n'
        '        blocks = [parsed];\n'
        '      }\n'
        '    } catch (e) {\n'
        '      /* If not JSON, treat as plain text */\n'
        '      blocks = [{ type: "text", text: String(msg.content || "") }];\n'
        '    }\n'
        '\n'
        '    html += renderContent(blocks);\n'
        '    html += "</div></div>";\n'
        '  }\n'
        '\n'
        '  container.innerHTML = html;\n'
        '\n'
        '  /* Show input area */\n'
        '  inputArea.classList.remove("hidden");\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Render an array of content blocks to HTML.\n'
        ' * @param {Array<Object>} blocks - Content blocks\n'
        ' * @returns {string} HTML string\n'
        ' */\n'
        'function renderContent(blocks) {\n'
        '  var parts = [];\n'
        '  for (var i = 0; i < blocks.length; i++) {\n'
        '    var b = blocks[i];\n'
        '    var btype = b.type || "";\n'
        '    if (btype === "text" && b.text) {\n'
        '      parts.push(renderMarkdown(b.text));\n'
        '    } else if (btype === "media") {\n'
        '      parts.push(renderMedia(b));\n'
        '    }\n'
        '    /* Skip tool_use, tool_result, thinking */\n'
        '  }\n'
        '  return parts.join("");\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Render a media content block to HTML.\n'
        ' * @param {Object} block - Media block with media_type, url, data, filename\n'
        ' * @returns {string} HTML string\n'
        ' */\n'
        'function renderMedia(block) {\n'
        '  var mt = block.media_type || "";\n'
        '  var url = block.url || "";\n'
        '  var data = block.data || "";\n'
        '  var fname = block.filename || "";\n'
        '\n'
        '  /* Build data URI if we have base64 data and no url */\n'
        '  if (!url && data && mt) {\n'
        '    url = "data:" + mt + ";base64," + data;\n'
        '  }\n'
        '\n'
        '  if (!url) {\n'
        '    return fname ? "<span>[" + esc(fname) + "]</span>" : "";\n'
        '  }\n'
        '\n'
        '  if (mt.startsWith("image/")) {\n'
        '    return \'<img src="\' + esc(url) + \'" alt="\' + esc(fname || "image") + \'" loading="lazy">\';\n'
        '  }\n'
        '  if (mt.startsWith("audio/")) {\n'
        '    return \'<audio controls src="\' + esc(url) + \'"></audio>\';\n'
        '  }\n'
        '  if (mt.startsWith("video/")) {\n'
        '    return \'<video controls src="\' + esc(url) + \'"></video>\';\n'
        '  }\n'
        '  return \'<a href="\' + esc(url) + \'" target="_blank">\' + esc(fname || "attachment") + "</a>";\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Lightweight markdown-to-HTML renderer.\n'
        ' * Handles fenced code blocks, inline code, headings, bold, italic,\n'
        ' * images, links, horizontal rules, unordered lists, and paragraphs.\n'
        ' * @param {string} text - Raw markdown text\n'
        ' * @returns {string} HTML string\n'
        ' */\n'
        'function renderMarkdown(text) {\n'
        '  if (!text) return "";\n'
        '\n'
        '  /* Escape HTML entities first */\n'
        '  text = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");\n'
        '\n'
        '  /* Extract fenced code blocks and replace with placeholders */\n'
        '  var codeBlocks = [];\n'
        '  text = text.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, function(m, lang, code) {\n'
        '    var idx = codeBlocks.length;\n'
        '    var cls = lang ? \' class="language-\' + lang + \'"\' : "";\n'
        '    codeBlocks.push("<pre><code" + cls + ">" + code + "</code></pre>");\n'
        '    return "\\x00CB" + idx + "\\x00";\n'
        '  });\n'
        '\n'
        '  /* Extract inline code and replace with placeholders */\n'
        '  var inlineCodes = [];\n'
        '  text = text.replace(/`([^`\\n]+)`/g, function(m, code) {\n'
        '    var idx = inlineCodes.length;\n'
        '    inlineCodes.push("<code>" + code + "</code>");\n'
        '    return "\\x00IC" + idx + "\\x00";\n'
        '  });\n'
        '\n'
        '  /* Headings */\n'
        '  text = text.replace(/^#### (.+)$/gm, "<h4>$1</h4>");\n'
        '  text = text.replace(/^### (.+)$/gm, "<h3>$1</h3>");\n'
        '  text = text.replace(/^## (.+)$/gm, "<h2>$1</h2>");\n'
        '  text = text.replace(/^# (.+)$/gm, "<h1>$1</h1>");\n'
        '\n'
        '  /* Horizontal rules */\n'
        '  text = text.replace(/^---+$/gm, "<hr>");\n'
        '\n'
        '  /* Unordered lists */\n'
        '  text = text.replace(/^[*-] (.+)$/gm, "<li>$1</li>");\n'
        '  text = text.replace(/((?:<li>.*<\\/li>\\n?)+)/g, "<ul>$1</ul>");\n'
        '\n'
        '  /* Images: ![alt](url) */\n'
        '  text = text.replace(/!\\[([^\\]]*)]\\(([^)]+)\\)/g, function(m, alt, url) {\n'
        '    if (/^(https?:|data:image\\/|assets\\/)/.test(url)) return \'<img src="\' + escAttr(url) + \'" alt="\' + escAttr(alt) + \'" loading="lazy">\';\n'
        '    return m;\n'
        '  });\n'
        '\n'
        '  /* Links: [text](url) */\n'
        '  text = text.replace(/\\[([^\\]]+)]\\(([^)]+)\\)/g, function(m, text2, url) {\n'
        '    if (/^(https?:|mailto:|#)/.test(url)) return \'<a href="\' + escAttr(url) + \'" target="_blank">\' + text2 + \'</a>\';\n'
        '    return m;\n'
        '  });\n'
        '\n'
        '  /* Bold+italic: ***text*** */\n'
        '  text = text.replace(/\\*\\*\\*(.+?)\\*\\*\\*/g, "<strong><em>$1</em></strong>");\n'
        '\n'
        '  /* Bold: **text** */\n'
        '  text = text.replace(/\\*\\*(.+?)\\*\\*/g, "<strong>$1</strong>");\n'
        '\n'
        '  /* Italic: *text* */\n'
        '  text = text.replace(/\\*(.+?)\\*/g, "<em>$1</em>");\n'
        '\n'
        '  /* Paragraph breaks: double newline */\n'
        '  text = text.replace(/\\n\\n+/g, "</p><p>");\n'
        '\n'
        '  /* Single newlines to <br> (but not inside block elements) */\n'
        '  text = text.replace(/\\n/g, "<br>");\n'
        '\n'
        '  /* Wrap in paragraph if not starting with block element */\n'
        '  if (!/^\\s*<(h[1-4]|pre|ul|hr|p)/.test(text)) {\n'
        '    text = "<p>" + text + "</p>";\n'
        '  }\n'
        '\n'
        '  /* Restore inline code placeholders */\n'
        '  text = text.replace(/\\x00IC(\\d+)\\x00/g, function(m, idx) {\n'
        '    return inlineCodes[parseInt(idx)];\n'
        '  });\n'
        '\n'
        '  /* Restore code block placeholders */\n'
        '  text = text.replace(/\\x00CB(\\d+)\\x00/g, function(m, idx) {\n'
        '    return codeBlocks[parseInt(idx)];\n'
        '  });\n'
        '\n'
        '  return text;\n'
        '}\n'
        '\n'
        '/** Initialize the timeline bar at the bottom. */\n'
        'function initTimeline() {\n'
        '  if (totalConvCount === 0) return;\n'
        '\n'
        '  /* Query monthly conversation counts */\n'
        '  var rows = query(\n'
        '    "SELECT strftime(\'%Y-%m\', created_at) AS month, COUNT(*) AS count FROM conversations GROUP BY month ORDER BY month"\n'
        '  );\n'
        '  if (rows.length === 0) return;\n'
        '\n'
        '  timelineData = rows;\n'
        '  timelineSelection = null;\n'
        '\n'
        '  /* Set start/end labels */\n'
        '  var startLabel = document.getElementById("timeline-start");\n'
        '  var endLabel = document.getElementById("timeline-end");\n'
        '  startLabel.textContent = rows[0].month;\n'
        '  endLabel.textContent = rows[rows.length - 1].month;\n'
        '\n'
        '  drawTimeline();\n'
        '\n'
        '  /* Mouse interaction state */\n'
        '  var canvas = document.getElementById("timeline-canvas");\n'
        '  var dragging = false;\n'
        '  var dragStartIdx = -1;\n'
        '\n'
        '  canvas.addEventListener("mousedown", function(e) {\n'
        '    if (timelineData.length === 0) return;\n'
        '    dragging = true;\n'
        '    var rect = canvas.getBoundingClientRect();\n'
        '    var x = e.clientX - rect.left;\n'
        '    var barW = rect.width / timelineData.length;\n'
        '    dragStartIdx = Math.min(Math.floor(x / barW), timelineData.length - 1);\n'
        '    dragStartIdx = Math.max(0, dragStartIdx);\n'
        '    timelineSelection = { start: dragStartIdx, end: dragStartIdx };\n'
        '    drawTimeline();\n'
        '  });\n'
        '\n'
        '  canvas.addEventListener("mousemove", function(e) {\n'
        '    if (!dragging || timelineData.length === 0) return;\n'
        '    var rect = canvas.getBoundingClientRect();\n'
        '    var x = e.clientX - rect.left;\n'
        '    var barW = rect.width / timelineData.length;\n'
        '    var idx = Math.min(Math.floor(x / barW), timelineData.length - 1);\n'
        '    idx = Math.max(0, idx);\n'
        '    timelineSelection = { start: Math.min(dragStartIdx, idx), end: Math.max(dragStartIdx, idx) };\n'
        '    drawTimeline();\n'
        '  });\n'
        '\n'
        '  canvas.addEventListener("mouseup", function() {\n'
        '    if (!dragging) return;\n'
        '    dragging = false;\n'
        '    if (!timelineSelection || timelineData.length === 0) return;\n'
        '    var fromMonth = timelineData[timelineSelection.start].month;\n'
        '    var toMonth = timelineData[timelineSelection.end].month;\n'
        '    activeFilters.dateFrom = fromMonth + "-01";\n'
        '    /* Set dateTo to end of month */\n'
        '    var toYear = parseInt(toMonth.substring(0, 4));\n'
        '    var toMon = parseInt(toMonth.substring(5, 7));\n'
        '    if (toMon === 12) { toYear++; toMon = 1; } else { toMon++; }\n'
        '    var nextMonth = toYear + "-" + (toMon < 10 ? "0" + toMon : toMon) + "-01";\n'
        '    activeFilters.dateTo = nextMonth;\n'
        '    loadConversations();\n'
        '  });\n'
        '\n'
        '  canvas.addEventListener("dblclick", function() {\n'
        '    timelineSelection = null;\n'
        '    activeFilters.dateFrom = null;\n'
        '    activeFilters.dateTo = null;\n'
        '    drawTimeline();\n'
        '    loadConversations();\n'
        '  });\n'
        '\n'
        '  /* Redraw on resize */\n'
        '  if (typeof ResizeObserver !== "undefined") {\n'
        '    new ResizeObserver(function() {\n'
        '      drawTimeline();\n'
        '    }).observe(canvas.parentElement);\n'
        '  }\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Draw the timeline bar chart on the canvas.\n'
        ' * Each month is a vertical bar, height proportional to count/maxCount.\n'
        ' * Selected range bars use accent color, others use border color.\n'
        ' * Handles devicePixelRatio for crisp rendering.\n'
        ' */\n'
        'function drawTimeline() {\n'
        '  var canvas = document.getElementById("timeline-canvas");\n'
        '  if (!canvas || timelineData.length === 0) return;\n'
        '  var ctx = canvas.getContext("2d");\n'
        '  var dpr = window.devicePixelRatio || 1;\n'
        '\n'
        '  /* Size canvas to match CSS layout */\n'
        '  var rect = canvas.getBoundingClientRect();\n'
        '  canvas.width = rect.width * dpr;\n'
        '  canvas.height = rect.height * dpr;\n'
        '\n'
        '  ctx.clearRect(0, 0, canvas.width, canvas.height);\n'
        '\n'
        '  var w = rect.width;\n'
        '  var h = rect.height;\n'
        '  var n = timelineData.length;\n'
        '  var barW = w / n;\n'
        '  var gap = Math.max(1, barW * 0.15);\n'
        '  var margin = 2;\n'
        '\n'
        '  /* Find max count */\n'
        '  var maxCount = 0;\n'
        '  for (var i = 0; i < n; i++) {\n'
        '    if (timelineData[i].count > maxCount) maxCount = timelineData[i].count;\n'
        '  }\n'
        '  if (maxCount === 0) return;\n'
        '\n'
        '  /* Read colors from CSS custom properties */\n'
        '  var style = getComputedStyle(canvas);\n'
        '  var barColor = style.getPropertyValue("--border").trim() || "#30363d";\n'
        '  var accentColor = style.getPropertyValue("--text-accent").trim() || "#58a6ff";\n'
        '\n'
        '  ctx.save();\n'
        '  ctx.scale(dpr, dpr);\n'
        '\n'
        '  for (var j = 0; j < n; j++) {\n'
        '    var barH = (timelineData[j].count / maxCount) * (h - margin * 2);\n'
        '    var x = j * barW + gap / 2;\n'
        '    var y = h - margin - barH;\n'
        '\n'
        '    var selected = timelineSelection &&\n'
        '      j >= timelineSelection.start && j <= timelineSelection.end;\n'
        '    ctx.fillStyle = selected ? accentColor : barColor;\n'
        '    ctx.fillRect(x, y, barW - gap, barH);\n'
        '  }\n'
        '\n'
        '  ctx.restore();\n'
        '}\n'
        '\n'
        '/** Return to librarian welcome state. */\n'
        'function goHome() {\n'
        '  activeConvId = null;\n'
        '  chatMode = "librarian";\n'
        '  librarianMessages = [];\n'
        '  var welcome = document.getElementById("welcome");\n'
        '  var container = document.getElementById("messages");\n'
        '  var titleEl = document.getElementById("conv-title");\n'
        '  var inputArea = document.getElementById("input-area");\n'
        '  var loading = document.getElementById("loading");\n'
        '  /* Clear messages but keep welcome */\n'
        '  var children = Array.from(container.children);\n'
        '  for (var i = 0; i < children.length; i++) {\n'
        '    if (children[i].id !== "welcome" && children[i].id !== "loading") {\n'
        '      container.removeChild(children[i]);\n'
        '    }\n'
        '  }\n'
        '  loading.classList.add("hidden");\n'
        '  welcome.classList.remove("hidden");\n'
        '  inputArea.classList.remove("hidden");\n'
        '  titleEl.textContent = "memex";\n'
        '  /* Deselect sidebar items */\n'
        '  var items = document.querySelectorAll(".conv-item.active");\n'
        '  for (var j = 0; j < items.length; j++) items[j].classList.remove("active");\n'
        '}\n'
        '\n'
    )


def _js_chat(schema_ddl: str = "") -> str:
    """Chat functionality: librarian agentic loop and resume-conversation chat."""
    # Escape schema_ddl for embedding in JS string
    escaped_ddl = (
        schema_ddl
        .replace("\\", "\\\\")
        .replace("`", "\\`")
        .replace("$", "\\$")
        .replace("'", "\\'")
        .replace("\r", "\\r")
        .replace("\n", "\\n")
    )
    return (
        '/* -- chat constants -------------------------------------------------- */\n'
        'var SCHEMA_DDL = \'' + escaped_ddl + '\';\n'
        '\n'
        'var LIBRARIAN_SYSTEM_PROMPT = "You are a knowledgeable librarian for this person\'s conversation archive. '
        'You can search and analyze their conversations using SQL queries.\\n\\n'
        'DATABASE SCHEMA:\\n" + SCHEMA_DDL + "\\n\\n'
        'IMPORTANT: This database is loaded via sql.js (WebAssembly SQLite). FTS5 virtual tables (messages_fts) are NOT available. '
        'Do NOT use MATCH queries. For text search, use: SELECT ... FROM messages WHERE content LIKE \'%term%\'\\n\\n'
        'QUERY GUIDELINES:\\n'
        '- Always query before guessing. Do not invent conversation titles or content.\\n'
        '- Use LIKE with % wildcards for text search on messages.content (JSON array of content blocks).\\n'
        '- Conversation titles are in conversations.title. Search there first for topic questions.\\n'
        '- Tags are in the tags table (conversation_id, tag). Join with conversations as needed.\\n'
        '- Cite conversation titles and dates in your answers.\\n'
        '- Keep answers concise. Show relevant excerpts when helpful.\\n'
        '- Limit result sets (LIMIT 20) to avoid overwhelming context.\\n\\n'
        'EXAMPLE QUERIES:\\n'
        '- Find conversations by topic: SELECT id, title, source, message_count, created_at FROM conversations WHERE title LIKE \'%topic%\' ORDER BY created_at DESC LIMIT 20\\n'
        '- Search message content: SELECT c.title, m.role, m.content FROM messages m JOIN conversations c ON c.id = m.conversation_id WHERE m.content LIKE \'%keyword%\' LIMIT 10\\n'
        '- Count by source: SELECT source, COUNT(*) as n FROM conversations GROUP BY source ORDER BY n DESC\\n'
        '- Recent activity: SELECT id, title, source, message_count, updated_at FROM conversations ORDER BY updated_at DESC LIMIT 10";\n'
        '\n'
        'var EXECUTE_SQL_TOOL = {\n'
        '  name: "execute_sql",\n'
        '  description: "Run a read-only SQL query against the conversation database. Returns results as a JSON array of objects. '
        'Use this to search conversations, count records, and find specific messages. Always use LIKE for text search (FTS5/MATCH is not available).",\n'
        '  input_schema: {\n'
        '    type: "object",\n'
        '    properties: {\n'
        '      query: {\n'
        '        type: "string",\n'
        '        description: "SQL SELECT query to execute"\n'
        '      }\n'
        '    },\n'
        '    required: ["query"]\n'
        '  }\n'
        '};\n'
        '\n'
        'var librarianMessages = [];\n'
        'var MAX_TOOL_ROUNDS = 5;\n'
        '\n'
        '/* -- chat dispatch --------------------------------------------------- */\n'
        '\n'
        '/** Dispatch to librarian or resume-conversation based on chatMode. */\n'
        'function sendMessage() {\n'
        '  var input = document.getElementById("message-input");\n'
        '  var text = input.value.trim();\n'
        '  if (!text) return;\n'
        '  input.value = "";\n'
        '  if (chatMode === "librarian") {\n'
        '    askLibrarian(text);\n'
        '  } else {\n'
        '    resumeConversation(text);\n'
        '  }\n'
        '}\n'
        '\n'
        '/* -- librarian ------------------------------------------------------- */\n'
        '\n'
        '/** Entry point for librarian mode. */\n'
        'async function askLibrarian(text) {\n'
        '  if (!db) return;\n'
        '  var container = document.getElementById("messages");\n'
        '  var welcome = document.getElementById("welcome");\n'
        '  var sendBtn = document.getElementById("send-btn");\n'
        '  var input = document.getElementById("message-input");\n'
        '\n'
        '  welcome.classList.add("hidden");\n'
        '  sendBtn.disabled = true;\n'
        '  input.disabled = true;\n'
        '\n'
        '  /* Add user message */\n'
        '  librarianMessages.push({ role: "user", content: text });\n'
        '  appendChatMessage(container, "user", text);\n'
        '\n'
        '  /* Create assistant message div */\n'
        '  var assistDiv = appendChatMessage(container, "assistant", "");\n'
        '  var assistContent = assistDiv.querySelector(".message-content");\n'
        '  container.scrollTop = container.scrollHeight;\n'
        '\n'
        '  try {\n'
        '    await runAgenticLoop(assistContent, container);\n'
        '  } catch (err) {\n'
        '    appendSystemMessage(container, "Could not reach the server. Check your connection.");\n'
        '    console.error("askLibrarian error:", err);\n'
        '  }\n'
        '\n'
        '  sendBtn.disabled = false;\n'
        '  input.disabled = false;\n'
        '  input.focus();\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Agentic loop: stream first response, then handle tool calls.\n'
        ' */\n'
        'async function runAgenticLoop(assistContent, container) {\n'
        '  var endpoint = localStorage.getItem("memex_endpoint") || "https://metafunctor-edge.queelius.workers.dev/v1/messages";\n'
        '  var apiKey = localStorage.getItem("memex_api_key") || "";\n'
        '  var model = localStorage.getItem("memex_model") || "claude-sonnet-4-6";\n'
        '\n'
        '  var headers = { "Content-Type": "application/json" };\n'
        '  if (apiKey) {\n'
        '    headers["x-api-key"] = apiKey;\n'
        '    headers["anthropic-version"] = "2023-06-01";\n'
        '    headers["anthropic-dangerous-direct-browser-access"] = "true";\n'
        '  }\n'
        '\n'
        '  /* First round: streaming */\n'
        '  var body = {\n'
        '    model: model,\n'
        '    max_tokens: 4096,\n'
        '    stream: true,\n'
        '    system: LIBRARIAN_SYSTEM_PROMPT,\n'
        '    messages: librarianMessages,\n'
        '    tools: [EXECUTE_SQL_TOOL]\n'
        '  };\n'
        '\n'
        '  var response = await fetch(endpoint, {\n'
        '    method: "POST",\n'
        '    headers: headers,\n'
        '    body: JSON.stringify(body)\n'
        '  });\n'
        '\n'
        '  if (!response.ok) {\n'
        '    var errText = await response.text();\n'
        '    appendSystemMessage(container, "API error " + response.status + ": " + errText);\n'
        '    return;\n'
        '  }\n'
        '\n'
        '  var result = await parseStreamResponse(response, assistContent, container);\n'
        '\n'
        '  /* Handle tool use rounds */\n'
        '  var rounds = 0;\n'
        '  while (result && result.type === "tool_use" && rounds < MAX_TOOL_ROUNDS) {\n'
        '    rounds++;\n'
        '\n'
        '    /* Add assistant message with tool_use to history */\n'
        '    librarianMessages.push({ role: "assistant", content: result.contentBlocks });\n'
        '\n'
        '    /* Execute the tool */\n'
        '    var toolResult = executeTool(result.toolUse);\n'
        '\n'
        '    /* Add tool_result to history */\n'
        '    librarianMessages.push({\n'
        '      role: "user",\n'
        '      content: [{\n'
        '        type: "tool_result",\n'
        '        tool_use_id: result.toolUse.id,\n'
        '        content: toolResult\n'
        '      }]\n'
        '    });\n'
        '\n'
        '    /* Create new assistant div for next response */\n'
        '    var nextDiv = appendChatMessage(container, "assistant", "");\n'
        '    assistContent = nextDiv.querySelector(".message-content");\n'
        '    container.scrollTop = container.scrollHeight;\n'
        '\n'
        '    /* Decide if this is the final forced round */\n'
        '    var nextBody = {\n'
        '      model: model,\n'
        '      max_tokens: 4096,\n'
        '      stream: true,\n'
        '      system: LIBRARIAN_SYSTEM_PROMPT,\n'
        '      messages: librarianMessages\n'
        '    };\n'
        '    if (rounds < MAX_TOOL_ROUNDS) {\n'
        '      nextBody.tools = [EXECUTE_SQL_TOOL];\n'
        '    }\n'
        '\n'
        '    var nextResponse = await fetch(endpoint, {\n'
        '      method: "POST",\n'
        '      headers: headers,\n'
        '      body: JSON.stringify(nextBody)\n'
        '    });\n'
        '\n'
        '    if (!nextResponse.ok) {\n'
        '      var errText2 = await nextResponse.text();\n'
        '      appendSystemMessage(container, "API error " + nextResponse.status + ": " + errText2);\n'
        '      return;\n'
        '    }\n'
        '\n'
        '    result = await parseStreamResponse(nextResponse, assistContent, container);\n'
        '  }\n'
        '\n'
        '  /* Add final text to history */\n'
        '  if (result && result.type === "text" && result.text) {\n'
        '    librarianMessages.push({ role: "assistant", content: result.text });\n'
        '  } else if (result && result.contentBlocks) {\n'
        '    librarianMessages.push({ role: "assistant", content: result.contentBlocks });\n'
        '  }\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Parse an SSE stream response. Renders text progressively.\n'
        ' * Returns {type: "text", text, contentBlocks} or {type: "tool_use", toolUse, contentBlocks}.\n'
        ' */\n'
        'async function parseStreamResponse(response, assistContent, container) {\n'
        '  var reader = response.body.getReader();\n'
        '  var decoder = new TextDecoder();\n'
        '  var buffer = "";\n'
        '  var accumulated = "";\n'
        '  var toolUse = null;\n'
        '  var toolJsonStr = "";\n'
        '  var contentBlocks = [];\n'
        '  var currentBlockIndex = -1;\n'
        '\n'
        '  while (true) {\n'
        '    var chunk = await reader.read();\n'
        '    if (chunk.done) break;\n'
        '    buffer += decoder.decode(chunk.value, { stream: true });\n'
        '\n'
        '    var lines = buffer.split("\\n");\n'
        '    buffer = lines.pop();\n'
        '\n'
        '    for (var k = 0; k < lines.length; k++) {\n'
        '      var line = lines[k].trim();\n'
        '      if (!line.startsWith("data: ")) continue;\n'
        '      var data = line.substring(6);\n'
        '      if (data === "[DONE]") continue;\n'
        '      try {\n'
        '        var evt = JSON.parse(data);\n'
        '        if (evt.type === "content_block_start") {\n'
        '          currentBlockIndex = evt.index;\n'
        '          if (evt.content_block && evt.content_block.type === "tool_use") {\n'
        '            toolUse = { id: evt.content_block.id, name: evt.content_block.name, input: {} };\n'
        '            toolJsonStr = "";\n'
        '          }\n'
        '        } else if (evt.type === "content_block_delta" && evt.delta) {\n'
        '          if (evt.delta.type === "text_delta") {\n'
        '            accumulated += evt.delta.text;\n'
        '            assistContent.innerHTML = renderMarkdown(accumulated);\n'
        '            container.scrollTop = container.scrollHeight;\n'
        '          } else if (evt.delta.type === "input_json_delta") {\n'
        '            toolJsonStr += evt.delta.partial_json;\n'
        '          }\n'
        '        } else if (evt.type === "content_block_stop") {\n'
        '          if (toolUse && toolJsonStr) {\n'
        '            try { toolUse.input = JSON.parse(toolJsonStr); } catch (e) {}\n'
        '          }\n'
        '          if (toolUse) {\n'
        '            contentBlocks.push({ type: "tool_use", id: toolUse.id, name: toolUse.name, input: toolUse.input });\n'
        '          } else if (accumulated) {\n'
        '            contentBlocks.push({ type: "text", text: accumulated });\n'
        '          }\n'
        '        }\n'
        '      } catch (e) {\n'
        '        /* Skip unparseable SSE lines */\n'
        '      }\n'
        '    }\n'
        '  }\n'
        '\n'
        '  if (toolUse) {\n'
        '    /* Show a subtle indicator that a query is running */\n'
        '    if (accumulated) {\n'
        '      assistContent.innerHTML = renderMarkdown(accumulated + "\\n\\n*Running query...*");\n'
        '    } else {\n'
        '      assistContent.innerHTML = renderMarkdown("*Running query...*");\n'
        '    }\n'
        '    return { type: "tool_use", toolUse: toolUse, contentBlocks: contentBlocks };\n'
        '  }\n'
        '  return { type: "text", text: accumulated, contentBlocks: contentBlocks };\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Execute a tool call. Only accepts execute_sql.\n'
        ' * Returns JSON string result.\n'
        ' */\n'
        'function executeTool(toolUse) {\n'
        '  if (toolUse.name !== "execute_sql") {\n'
        '    return JSON.stringify({ error: "Unknown tool: " + toolUse.name });\n'
        '  }\n'
        '  var sql = (toolUse.input.query || "").trim();\n'
        '  var firstWord = sql.split(/\\s/)[0].toUpperCase();\n'
        '  if (firstWord !== "SELECT" && firstWord !== "WITH" && firstWord !== "PRAGMA" && firstWord !== "EXPLAIN") {\n'
        '    return JSON.stringify({ error: "Only SELECT queries are allowed. Got: " + firstWord });\n'
        '  }\n'
        '  try {\n'
        '    var rows = query(sql);\n'
        '    if (rows.length > 50) {\n'
        '      rows = rows.slice(0, 50);\n'
        '      rows.push({ _truncated: true, _message: "Results truncated to 50 rows" });\n'
        '    }\n'
        '    return JSON.stringify(rows);\n'
        '  } catch (e) {\n'
        '    return JSON.stringify({ error: e.message });\n'
        '  }\n'
        '}\n'
        '\n'
        '/**\n'
        ' * Create and append a chat message div.\n'
        ' * @returns {HTMLElement} The created div\n'
        ' */\n'
        'function appendChatMessage(container, role, text) {\n'
        '  var div = document.createElement("div");\n'
        '  div.className = "message " + role;\n'
        '  var roleDiv = document.createElement("div");\n'
        '  roleDiv.className = "message-role";\n'
        '  roleDiv.textContent = role;\n'
        '  div.appendChild(roleDiv);\n'
        '  var contentDiv = document.createElement("div");\n'
        '  contentDiv.className = "message-content";\n'
        '  if (text) contentDiv.innerHTML = renderMarkdown(text);\n'
        '  div.appendChild(contentDiv);\n'
        '  container.appendChild(div);\n'
        '  return div;\n'
        '}\n'
        '\n'
        '/** Append a system/error message. */\n'
        'function appendSystemMessage(container, text) {\n'
        '  var div = document.createElement("div");\n'
        '  div.className = "message system";\n'
        '  div.innerHTML = \'<div class="message-role">system</div><div class="message-content" style="color: var(--danger);">\' + esc(text) + "</div>";\n'
        '  container.appendChild(div);\n'
        '  container.scrollTop = container.scrollHeight;\n'
        '}\n'
        '\n'
        '/* -- resume conversation chat ---------------------------------------- */\n'
        '\n'
        '/**\n'
        ' * Resume a conversation via Anthropic API (existing per-conversation chat).\n'
        ' */\n'
        'async function resumeConversation(userText) {\n'
        '  if (!db || !activeConvId) return;\n'
        '\n'
        '  var endpoint = localStorage.getItem("memex_endpoint") || "https://metafunctor-edge.queelius.workers.dev/v1/messages";\n'
        '  var apiKey = localStorage.getItem("memex_api_key") || "";\n'
        '\n'
        '  var sendBtn = document.getElementById("send-btn");\n'
        '  sendBtn.disabled = true;\n'
        '\n'
        '  var model = localStorage.getItem("memex_model") || "claude-sonnet-4-6";\n'
        '  var systemPrompt = localStorage.getItem("memex_system_prompt") || "";\n'
        '\n'
        '  /* Build message history from DB */\n'
        '  var dbMessages = query(\n'
        '    "SELECT role, content FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",\n'
        '    [activeConvId]\n'
        '  );\n'
        '  var history = [];\n'
        '  for (var i = 0; i < dbMessages.length; i++) {\n'
        '    var msg = dbMessages[i];\n'
        '    var text = "";\n'
        '    try {\n'
        '      var parsed = JSON.parse(msg.content);\n'
        '      if (Array.isArray(parsed)) {\n'
        '        for (var j = 0; j < parsed.length; j++) {\n'
        '          if (parsed[j].type === "text" && parsed[j].text) text += parsed[j].text;\n'
        '        }\n'
        '      } else if (typeof parsed === "string") {\n'
        '        text = parsed;\n'
        '      }\n'
        '    } catch (e) {\n'
        '      text = String(msg.content || "");\n'
        '    }\n'
        '    if (text && (msg.role === "user" || msg.role === "assistant")) {\n'
        '      history.push({ role: msg.role, content: text });\n'
        '    }\n'
        '  }\n'
        '\n'
        '  /* Add user\'s new message */\n'
        '  history.push({ role: "user", content: userText });\n'
        '\n'
        '  /* Get the last message ID for parent_id */\n'
        '  var lastMsgRows = query(\n'
        '    "SELECT id FROM messages WHERE conversation_id = ? ORDER BY created_at DESC LIMIT 1",\n'
        '    [activeConvId]\n'
        '  );\n'
        '  var parentId = lastMsgRows.length > 0 ? lastMsgRows[0].id : null;\n'
        '\n'
        '  /* INSERT user message into DB */\n'
        '  var userMsgId = crypto.randomUUID();\n'
        '  var now = new Date().toISOString();\n'
        '  var userContent = JSON.stringify([{ type: "text", text: userText }]);\n'
        '  exec(\n'
        '    "INSERT INTO messages (conversation_id, id, role, content, parent_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",\n'
        '    [activeConvId, userMsgId, "user", userContent, parentId, now]\n'
        '  );\n'
        '\n'
        '  /* Render user message in the conversation view */\n'
        '  var container = document.getElementById("messages");\n'
        '  var userDiv = document.createElement("div");\n'
        '  userDiv.className = "message user";\n'
        '  userDiv.innerHTML = \'<div class="message-role">user</div><div class="message-content">\' + renderMarkdown(userText) + "</div>";\n'
        '  container.appendChild(userDiv);\n'
        '\n'
        '  /* Create assistant message div for streaming */\n'
        '  var assistDiv = document.createElement("div");\n'
        '  assistDiv.className = "message assistant";\n'
        '  assistDiv.innerHTML = \'<div class="message-role">assistant</div><div class="message-content"></div>\';\n'
        '  container.appendChild(assistDiv);\n'
        '  var assistContent = assistDiv.querySelector(".message-content");\n'
        '  container.scrollTop = container.scrollHeight;\n'
        '\n'
        '  /* Build request body */\n'
        '  var body = { model: model, max_tokens: 4096, stream: true, messages: history };\n'
        '  if (systemPrompt) body.system = systemPrompt;\n'
        '\n'
        '  var accumulated = "";\n'
        '  try {\n'
        '    var headers = { "Content-Type": "application/json" };\n'
        '    if (apiKey) {\n'
        '      headers["x-api-key"] = apiKey;\n'
        '      headers["anthropic-version"] = "2023-06-01";\n'
        '      headers["anthropic-dangerous-direct-browser-access"] = "true";\n'
        '    }\n'
        '    var response = await fetch(endpoint, {\n'
        '      method: "POST",\n'
        '      headers: headers,\n'
        '      body: JSON.stringify(body)\n'
        '    });\n'
        '\n'
        '    if (!response.ok) {\n'
        '      var errText = await response.text();\n'
        '      throw new Error("API error " + response.status + ": " + errText);\n'
        '    }\n'
        '\n'
        '    /* Read SSE stream */\n'
        '    var reader = response.body.getReader();\n'
        '    var decoder = new TextDecoder();\n'
        '    var sseBuffer = "";\n'
        '\n'
        '    while (true) {\n'
        '      var result = await reader.read();\n'
        '      if (result.done) break;\n'
        '      sseBuffer += decoder.decode(result.value, { stream: true });\n'
        '\n'
        '      var lines = sseBuffer.split("\\n");\n'
        '      sseBuffer = lines.pop();\n'
        '\n'
        '      for (var k = 0; k < lines.length; k++) {\n'
        '        var line = lines[k].trim();\n'
        '        if (!line.startsWith("data: ")) continue;\n'
        '        var data = line.substring(6);\n'
        '        if (data === "[DONE]") continue;\n'
        '        try {\n'
        '          var evt = JSON.parse(data);\n'
        '          if (evt.type === "content_block_delta" && evt.delta && evt.delta.type === "text_delta") {\n'
        '            accumulated += evt.delta.text;\n'
        '            assistContent.innerHTML = renderMarkdown(accumulated);\n'
        '            container.scrollTop = container.scrollHeight;\n'
        '          }\n'
        '        } catch (e) {\n'
        '          /* Skip unparseable SSE lines */\n'
        '        }\n'
        '      }\n'
        '    }\n'
        '  } catch (err) {\n'
        '    if (!accumulated) {\n'
        '      assistContent.innerHTML = \'<span style="color: var(--danger);">Error: \' + esc(err.message) + "</span>";\n'
        '    }\n'
        '    console.error("resumeConversation error:", err);\n'
        '  }\n'
        '\n'
        '  /* INSERT assistant message into DB */\n'
        '  if (accumulated) {\n'
        '    var assistMsgId = crypto.randomUUID();\n'
        '    var assistNow = new Date().toISOString();\n'
        '    var assistContentJson = JSON.stringify([{ type: "text", text: accumulated }]);\n'
        '    exec(\n'
        '      "INSERT INTO messages (conversation_id, id, role, content, parent_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",\n'
        '      [activeConvId, assistMsgId, "assistant", assistContentJson, userMsgId, assistNow]\n'
        '    );\n'
        '\n'
        '    /* Update conversation metadata */\n'
        '    exec(\n'
        '      "UPDATE conversations SET message_count = (SELECT count(*) FROM messages WHERE conversation_id = ?), updated_at = ? WHERE id = ?",\n'
        '      [activeConvId, assistNow, activeConvId]\n'
        '    );\n'
        '  }\n'
        '\n'
        '  sendBtn.disabled = false;\n'
        '}\n'
        '\n'
        '/** Download the current database as a .db file. */\n'
        'function downloadDb() {\n'
        '  if (!db) return;\n'
        '  var data = db.export();\n'
        '  var blob = new Blob([data], { type: "application/octet-stream" });\n'
        '  var url = URL.createObjectURL(blob);\n'
        '  var a = document.createElement("a");\n'
        '  a.href = url;\n'
        '  a.download = "conversations.db";\n'
        '  a.click();\n'
        '  URL.revokeObjectURL(url);\n'
        '}\n'
        '\n'
    )


def _js_settings() -> str:
    """Settings overlay, localStorage, boot."""
    return (
        '/** Load settings from localStorage into form fields. */\n'
        'function loadSettings() {\n'
        '  var endpoint = localStorage.getItem("memex_endpoint") || "";\n'
        '  var apiKey = localStorage.getItem("memex_api_key") || "";\n'
        '  var systemPrompt = localStorage.getItem("memex_system_prompt") || "";\n'
        '  document.getElementById("setting-endpoint").value = endpoint;\n'
        '  document.getElementById("setting-api-key").value = apiKey;\n'
        '  document.getElementById("setting-system-prompt").value = systemPrompt;\n'
        '}\n'
        '\n'
        '/** Save settings from form fields to localStorage. */\n'
        'function saveSettings() {\n'
        '  var endpoint = document.getElementById("setting-endpoint").value.trim();\n'
        '  var apiKey = document.getElementById("setting-api-key").value.trim();\n'
        '  var systemPrompt = document.getElementById("setting-system-prompt").value.trim();\n'
        '  localStorage.setItem("memex_endpoint", endpoint);\n'
        '  localStorage.setItem("memex_api_key", apiKey);\n'
        '  localStorage.setItem("memex_system_prompt", systemPrompt);\n'
        '  toggleSettings();\n'
        '}\n'
        '\n'
        '/** Toggle the settings overlay visibility. */\n'
        'function toggleSettings() {\n'
        '  var overlay = document.getElementById("settings-overlay");\n'
        '  overlay.classList.toggle("visible");\n'
        '}\n'
        '\n'
        '/* -- theme ----------------------------------------------------------- */\n'
        'function initTheme() {\n'
        '  var saved = localStorage.getItem("memex_theme");\n'
        '  var theme;\n'
        '  if (saved === "light" || saved === "dark") {\n'
        '    theme = saved;\n'
        '  } else {\n'
        '    theme = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";\n'
        '  }\n'
        '  document.documentElement.setAttribute("data-theme", theme);\n'
        '  updateThemeIcon(theme);\n'
        '}\n'
        '\n'
        'function toggleTheme() {\n'
        '  var current = document.documentElement.getAttribute("data-theme") || "dark";\n'
        '  var next = current === "dark" ? "light" : "dark";\n'
        '  document.documentElement.setAttribute("data-theme", next);\n'
        '  localStorage.setItem("memex_theme", next);\n'
        '  updateThemeIcon(next);\n'
        '}\n'
        '\n'
        'function updateThemeIcon(theme) {\n'
        '  var btn = document.getElementById("theme-toggle");\n'
        '  if (btn) btn.textContent = theme === "dark" ? "\\u263E" : "\\u2600";\n'
        '}\n'
        '\n'
        '/* Init theme immediately (before DB loads) */\n'
        'initTheme();\n'
        '\n'
        '/* -- boot ------------------------------------------------------------ */\n'
        'initApp().catch(function(err) {\n'
        '  console.error("initApp failed:", err);\n'
        '  var loading = document.getElementById("loading");\n'
        '  if (loading) loading.textContent = "error: " + err.message;\n'
        '});\n'
    )
